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
?
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>'.
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"; }>'.
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; }>'.