I have a list of strings:
const itemTypes = [
"spoon",
"fork",
"knife",
] as const;
// union type "spoon" | "fork" | "knife"
type ItemType = typeof itemTypes[number];
... and want a type with keys from that list:
type ItemProperties = {
fork: {
spikeCount: number;
};
spoon: {
kind: "table" | "tea";
}
knife: {
isSharp: boolean;
}
}
In the code above, I would like to constrain type ItemProperties to only allow keys from itemTypes, so for example if the list of itemTypes later changes to not include fork, the type system will warn me. Same if I try to include a different kind of key (e.g. plate: { diameterCm: number }) in the type.
I would like to later use these types to define a function that has a lookup type parameter, if that matters:
function makeItem<NewItemType extends ItemType>(
newItemType: NewItemType,
newItemProperties: ItemProperties[NewItemType]
) {
/* whatever */
}
Is this kind of limitation (essentially saying "type X has to indexable by type Y") possible to describe in TS? Or is there possibly a simpler way to conditionally decide the type of the second function parameter based on the type of the first parameter?
TypeScript doesn't have a native type-level version of the satisfies operator to check purely type-level constraints. There's a suggestion at microsoft/TypeScript#5222 for one, but it's not currently part of the language.
So if you want something like it, you have to write it yourself. Here's one possibility:
type EnforceKeys<
K extends PropertyKey,
T extends { [P in K | keyof T]: P extends K ? unknown : never }
> = T;
The type EnforceKeys<K, T> accepts a union of keylike literal types for K, and then an objectlike type to check as T. The intent is that this will compile if and only if the keys of T are exactly the same as K: no missing keys, and no extra keys. Note that the type evaluates as just T, so the only purpose is checking T against K.
This check happens via the generic constraint T extends { [P in K | keyof T]: P extends K ? unknown : never }. That type on the right is a mapped type with all the keys from K and those in T. For each such key P, we check the value type against the permissive unknown type if that key is also part of K, and the impossible never type. What this means: if there are any extra keys in T that are not mentioned in K, then we'd be checking that property value against never and this will very likely fail (e.g., {extraKey: string} checked against {extraKey: never} will fail). If there are no extra keys, then we're effectively checking T against Record<K, unknown>, which will pass if and only if T is not missing any keys.
Let's test it out:
type ItemProperties = EnforceKeys<ItemType, {
fork: { spikeCount: number; };
spoon: { kind: "table" | "tea"; }
knife: { isSharp: boolean; }
}>; // okay, no error
type MissingOne = EnforceKeys<ItemType, {
fork: {},
spoon: {}
}> // error! Property 'knife' is missing
type ExtraOne = EnforceKeys<ItemType, {
fork: {},
spoon: {},
knife: {},
spork: {}
}> // error! Types of property 'spork' are incompatible.
Looks good. The ItemProperties type definition has no errors, while both the MissingOne and ExtraOne definitions have errors which mention the problematic property. Note that the error squigglies are covering the entire T argument, which isn't ideal, but you really have very little control over how TypeScript displays errors on pure type-level code. (For values like {fork: {}, spoon: {}, knife: {}, spork: {}}, you could expect an error on just the spork part. But for the type that looks the same, TypeScript will just say that the whole type is wrong.)
Again, this is just one possibility, so depending on one's use case, a different approach might be more appropriate.