This post demonstrates a TypeScript pattern I've been experimenting with for the last year or so.
I've been calling this pattern "inductive type constraints", but perhaps a better name would be "smart constructors", since they have "correct by construction" semantics (here's a nice article about them).
"Smart constructors" definitely sounds cooler, but "inductive types" are what they're called in other typed languages, so I went with that.
Before we dig into how they work, here are a few examples of how they can be used to solve a few long-standing TypeScript issues:
Exact
- "exact types" is historically the 2nd-most requested feature of all time
-
Exact
ensures that a type does not have any extra properties
Here's the implementation of Exact
(Playground):
type Exact<T, S> = [keyof T] extends [keyof S]
? [T] extends [S] ? { [K in keyof T]: T[K] }
: S : never
declare function exact<S, T extends Exact<T, S>>(
x: S,
y: T
): T
exact({ a: 1 }, { a: 1, b: 2 })
// ^ 🚫 nope
If you're not sure how or why this works, keep reading -- I talk about that in the second half of this post.
Json
- One of the oldest TypeScript issues (#187)
Here's the implementation of Json
(Playground):
type Scalar = null | boolean | number | string
type Json<T>
= [T] extends [Scalar | undefined] ? Scalar
: [T] extends [{ [x: number]: unknown }] ? { [K in keyof T]: Json<T[K]> }
: never
declare function json<T extends Json<T>>(json: T): T
json('abc') // ok ✅
json({} as { x?: number }) // ok ✅
json(/abc/) // nope 🚫
json({ x: [new Date] }) // nope 🚫
How does it work?
In the TypeScript discord I outlined the rules that apply when defining an inductive constraint.
There are 2 rules you need to follow.
Rule #1
If you're applying a constraint to a type parameter directly, you can't distribute the parameter or you'll get a circular reference.
What does that look like in practice?
type StringLiteral<T>
= [T] extends [string]
? string extends T ? never
: string
: never
declare function test<T extends StringLiteral<T>>(x: T): T
///////////////////////////
/// expect: OK ✅ ///
let ex_01 = test('abc')
// ^? let ex_01: "abc"
let ex_02 = test(`abc${String()}def`)
// ^? let ex_02: `abc${string}def`
//////////////////////////////////////
/// expect: red squiggles 🚫 ///
test(String())
test('abc' + '')
Rule #2
Instead of returning
T
(the type you're constraining), you need to return the least upper bound.
What does that look like in practice?
This one's a bit more subtle, so I encourage you to play around with the example in the playground to build an intuition for how it works.
In short: you can't return T
itself, or T
will be circular.
Instead, return the least constrained (most permissive) version of the type you're constructing.
Let's look at the StringLiteral
example again:
type StringLiteral<T>
= [T] extends [string]
? string extends T ? never
: string // <-- here
: never
Instead of returning T
, I return the constraint that I want to apply.
This will feel weird the first few times you do it. Once it clicks, though, if you're like me, you'll gain a new appreciation for one of the many subtleties of the TypeScript type system.
More examples are in the works, but that's enough for today.
Inductive types are much more powerful than they might seem at first glance. And they're important to keep in your toolbelt, especially if you're a library author.
Thanks for reading!