4 min read
Check that two types are the same in TypeScript

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.