TypeScript: Match the exact object shape
TypeScript is a structural type system. This means as long as your data structure satisfies a contract, TypeScript will allow it. Even if you have too many keys declared.
type Person = {
first: string, last: string
}
declare function savePerson(person: Person);
const tooFew = { first: 'Stefan' };
const exact = { first: 'Stefan', last: 'Baumgartner' }
const tooMany = { first: 'Stefan', last: 'Baumgartner', age: 37 }
savePerson(tooFew); // 💥 doesn't work
savePerson(exact); // ✅ satisfies the contract
savePerson(tooMany); // ✅ satisfies the contract
This complements the way JavaScript works really well and gives you both flexibility and type safety. There are some scenarios where you might want the exact shape of an object. E.g. when you send data to backend that errors if it gets too much information.
savePerson(tooMany); // ✅ satisfies the contract, 💥 bombs the backend
In a JS world, always make sure to explicitly send payloads in scenarios like that, don’t rely on types alone. But while types can’t help you getting communication 100% correct, we can get a little compilation time help to make sure we don’t stray off our own path. All with the help of conditional types.
First, we check if the object we want to validate matches the original shape:
type ValidateShape<T, Shape> =
T extends Shape ? ...
With that call we make sure that the object we pass as parameter is a subtype of Shape
.
Then, we check for any extra keys:
type ValidateShape<T, Shape> =
T extends Shape ?
+ Exclude<keyof T, keyof Shape> extends never ? ...
So how does this work? Exclude<T, U>
is defined as T extends U ? never : T
. We pass
in the keys the object to validate and the shape. Let’s say Person
is our shape, and
tooMany = { first: 'Stefan', last: 'Baumgartner', age: 37 }
is the object we want
to validate. This are our keys:
keyof Person = 'first' | 'last'
keyof typeof tooMany = 'first' | 'last' | 'age'
'first'
and 'last'
are in both union types, so they return never
, age
returns
itself because it isn’t available in Person
:
keyof Person = 'first' | 'last'
keyof typeof tooMany = 'first' | 'last' | 'age'
Exclude<keyof typeof tooMany, keyof Person> = 'age';
Is it an exact match, Exclude<T, U>
returns never
:
keyof Person = 'first' | 'last'
keyof typeof exact = 'first' | 'last'
Exclude<keyof typeof exact, keyof Person> = never;
In ValidateShape
we check if Exclude
extends never
, meaning we don’t have any extrac keys.
If this condition is true, we return the type we want to validate.
In all other conditions, we return never
:
type ValidateShape<T, Shape> =
T extends Shape ?
Exclude<keyof T, keyof Shape> extends never ?
+ T : never : never;
Let’s adapt our original function:
declare function savePerson<T>(person: ValidateShape<T, Person>): void;
With that, it’s impossible to pass objects that don’t exactly match the shape of the type we expect:
savePerson(tooFew); // 💥 doesn't work
savePerson(exact); // ✅ satisfies the contract
savePerson(tooMany); // 💥 doesn't work
There’s a playground for you to fiddle around. This helper won’t get you around runtime checks, but it is a helping hand during development.