How to assert two interfaces contain the same keys in TypeScript?

Viewed 429

Given the interfaces Foo and Bar, how can I statically assert both types have the same keys?

Background: I have an interface that needs to be used as both a database DTO and also as a 'model' type. For the database DTO, primitives will be regular JavaScript primitives. For the 'model' type, primitives will be nominal types (e.g. AccountId instead of string, etc.). Currently, I am achieving this by defining 2 interfaces: one for the DTO, and one for the model. However, I want some way to ensure these interfaces don't drift:

interface Dto {
  orderId: string;
  invoiceNo: number;
}
interface Model {
  orderId: OrderId;
  invoiceNo: InvoiceNumber;
}
assertKeysEqual<Dto, Model>() // Compiles OK
interface Dto {
  orderId: string;
  invoiceNo: number;
  x: any;
}
interface Model {
  orderId: OrderId;
  invoiceNo: InvoiceNumber;
}
assertKeysEqual<Dto, Model>() // Compile ERROR ('Model' does not contain 'x')
interface Dto {
  orderId: string;
  invoiceNo: number;
}
interface Model {
  orderId: OrderId;
  invoiceNo: InvoiceNumber;
  x: any;
}
assertKeysEqual<Dto, Model>() // Compile ERROR ('Dto' does not contain 'x')

Nominal types are defined using the following method. However, more generally: the shape of the key types could be completely different between the types. For example, in one type you may have order: OrderId, which could be replaced with order: OrderEntity in its "expanded" counterpart type.

export interface OrderId extends String {
  _OrderId: string;
}
export function OrderId(value: string): OrderId {
  return value as any;
}

Any idea how to implement something like assertKeysEqual?

2 Answers

There's also a type-only solution that gives a somewhat readable error message:

type AssertKeysEqual<
  T1 extends Record<keyof T2, any>,
  T2 extends Record<keyof T1, any>
> = never

type Assertion = AssertKeysEqual<{a:1}, {a:1, b: 'x'}>
// ERROR: Property 'b' is missing in type '{ a: 1; }' but required in type 'Record<"a" | "b", any>'.

TypeScript playground

UPDATE: In TypeScript 4.2+, type-alias preservation (docs) allows for slightly more pleasant error messages, if you introduce extra type ShapeOf for the record:

type ShapeOf<T> = Record<keyof T, any>
type AssertKeysEqual<X extends ShapeOf<Y>, Y extends ShapeOf<X>> = never

type Assertion = AssertKeysEqual<{a:1}, {a:1, b: 'x'}>
// ERROR: Property 'b' is missing in type '{ a: 1; }' but required in 
// type 'ShapeOf<{ a: 1; b: "x"; }>'.

TypeScript playground

You can check that the keys of each interface are a subset of the other:

type AssertEqualKeys<T1 extends object, T2 extends object> =
  [
    keyof T1 extends keyof T2 ? 1 : 0,
    keyof T2 extends keyof T1 ? 1 : 0
  ] extends [1,1] ?  true : false;

const areEqual: AssertEqualKeys<Dto,Model> = true;

That type will resolve to false if the keys drift from one another, and you'll be notified of the error.

playground link

Related