It is sometimes useful to ensure that two types are the same, and to get an error if they drift apart. For example, I recently needed a list of all the possible runtime values for a type defined in a library, something like this:
// in some-library-i-cant-change
export type ReadableField =
| "name"
| "email"
| "phone"
| "id";
// in my own code
const readableFields = [
"name",
"email",
"phone",
"id",
] as const;
function isReadableField(
field: string,
): field is ReadableField {
return readableFields.includes(field as ReadableField);
}
That works at this moment, but if the library adds a new value, 'birthday'
, then my function will be out of date. I’d like to get a type error if that happens so I can update my list. Here’s how we can do that:
const readableFields = [
"name",
"email",
"phone",
"id",
] as const satisfies ReadableField[];
type UncoveredReadableFields = Exclude<
ReadableField,
(typeof readableFields)[number]
>;
expectNever(
"some field isn't listed" as UncoveredReadableFields,
);
Try it out in a Typescript Playground. You should see that adding an extra field will cause an error because the list will no longer satisfies ReadableField[]
. Omitting a field will also cause an error because UncoveredReadableFields
will not be the never
type.
If you don’t have an expectNever
function, you can define it as function expectNever(_x: never) { }
.
Another use case: No additional properties
This is useful in other contexts as well. For example, today I was working with an interface representing a product metrics table. It has a large number of fields, but they’re nearly all optional.
interface ProductMetric {
event_name: string;
session_id?: string;
client?: string;
client_version?: string;
client_event_epoch?: number;
container_id?: string;
user_id?: string;
institution_id?: string;
result?: string;
source?: string;
chat_id?: string;
duration_ms?: number;
// all the other fields we might need
}
In order to offer better type safety in the application, I’m defining subtypes that are more restrictive.
interface ContextAddedMetric extends ProductMetric {
event_name: "context_added";
source: "app_context" | "text_selection";
}
interface MessageSentMetric extends ProductMetric {
event_name: "message_sent";
chat_id: string;
}
interface ResponseReceivedMetric extends ProductMetric {
event_name: "response_received";
chat_id: string;
duration_ms: number;
}
type SaferMetric =
| ContextAddedMetric
| MessageSentMetric
| ResponseReceivedMetric;
These events are safer to use because they remind us to include the right fields for each event. But there’s a danger we could introduce a new field name, which wouldn’t be recognized by the backend. To prevent this, we can use the same trick: we’ll make sure that the keyof
the SaferMetric
type matches the keyof
the ProcuctMetric
type.
type ExtraFieldsIntroduced = Exclude<
keyof SaferMetric, // oops, there's a bug here. See below
keyof ProductMetric
>;
expectNever(
"an extra field was introduced" as ExtraFieldsIntroduced,
);
An issue with keyof
and unions
After publishing this, my colleague Steve Marquis pointed out that this doesn’t work. keyof (A | B | C)
will always give the keys common to all three types. To fix it, we need a strange-looking bit of code:
type SaferMetricKeys<K = SaferMetric> = K extends K
? keyof K
: never;
The name for this trick is Distributive Conditional Types. To quote the TypeScript docs, “When conditional types act on a generic type, they become distributive when given a union type.” For us, that means that within the <condition> ? <then> : <else>
expression, the <then>
bit is evaluated for each part of the union type.
type SaferMetricKeys<K = SaferMetric> = K extends K
? keyof K
: never;
// the left-hand side of the `extends` is a union type, so any expressions
// involving it are distributed to each branch of the union
type SaferMetricKeys = SaferMetric extends SaferMetric
? keyof ContextAddedMetric | keyof MessageSentMetric | keyof ResponseReceivedMetric
: never;
// SaferMetric *does* extend SaferMetric, so this simplifies to:
keyof ContextAddedMetric | keyof MessageSentMetric | keyof ResponseReceivedMetric
// which is what we wanted!
Try it out on the TypeScript playground
This is a good way to lean on TypeScript to help you maintain invariants in your code. It’s probably worth adding an explanatory comment where you use it, as I don’t think it’s a very common pattern.