Constraining literal types with generics in TypeScript
UPD: TypeScript 4.9 was announced on September 23, 2022 and the new satisfies
operator has made this article obsolete.
Before TS 4.9:
type A = { s: string };
type B = { n: number };
type C = { b: boolean };
type Constraint = Record<string, A | B | C>;
function identity<T extends Constraint>(x: T): T {
return x;
}
const o = identity({
a: { s: "a" },
});
type T = typeof o.a; // => { s: string }
After TS 4.9:
type A = { s: string };
type B = { n: number };
type C = { b: boolean };
type Constraint = Record<string, A | B | C>;
const o = {
a: { s: "a" },
} satisfies Constraint;
type T = typeof o.a; // => { s: string }
Let’s say you need an object with the shape of Record<string, { s: string } | { n: number } | { b: boolean }>
. You fire up your IDE and write down something like this:
type A = { s: string };
type B = { n: number };
type C = { b: boolean };
type MyObject = Record<string, A | B | C>;
Great, you can now type check your object literals:
const o1: MyObject = {
a: { s: 1 }, // error: Type 'number' is not assignable to type 'string'.
};
// all good
const o2: MyObject = {
a: { s: "str" },
};
Some time later you decide that you need to know the type of o.a
, but it can’t be inferred! Due to the MyObject
type reference, the object literal type information is lost and all you are left with is A | B | C
:
type T = typeof o2.a; // => A | B | C
Moreover, because string
is used as an indexed access type of Record
, TS will not warn you about the non-existent property access:
// TS guess: `A | B | C`
// Harsh reality: `undefined`
const value = o2.j;
The autocomplete is also not available in this case.
Fortunately, we can leverage the power of generics to both type check the object literal and preserve type information:
type Constraint = Record<string, A | B | C>;
function identity<T extends Constraint>(x: T): T {
return x;
}
const o = identity({
a: { s: "a" },
});
type T = typeof o.a; // => { s: string }
The extends
clause of the type parameter enforces the correct type of the object:
const o = identity({
a: { s: 1 }, // error: Type 'number' is not assignable to type 'string'.
});
At the same time, the literal type information is preserved because the identity
function returns exactly what it have received: T
, which is the (literal) type of the object literal.
Read more on generic constraints in the TS handbook: https://www.typescriptlang.org/docs/handbook/2/generics.html#generic-constraints