typescripttypescript-generics

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


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?


Solution

  • 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

    UPDATE 2: If we declare ShapeOf as a homomorphic (structure-preserving) mapped type {[K in keyof T]: unknown} instead of a record, it can also handle optional properties.

    type ShapeOf<T> = {[K in keyof T]: unknown}
    type AssertKeysEqual<X extends ShapeOf<Y>, Y extends ShapeOf<X>> = never
    
    type Assertion1 = AssertKeysEqual<{ a?: string }, { a?: string }>; // No error
    
    type Assertion2 = AssertKeysEqual<{ a?: string }, { a: string }>;
    // ERROR: Property 'a' is optional in type '{ a?: string | undefined; }'
    // but required in type 'ShapeOf<{ a: string; }>'.
    

    TypeScript playground