Stefan Baumgartner

Web ops, performance and front-end

TypeScript: Match the exact object shape

19 August 2019 by @ddprrt | Posted in: TypeScript

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.

More articles about TypeScript

Comments? Shoot me a tweet!