TypeScript Piped Inference
2023-01-29
Summary
In this article, we demonstrate a powerful TypeScript trick that allows types to
be inferred using the extends
keyword on unknown
types.
The challenge
We want to be able to write something like this:
const bar: 'bar' =
getMember(
{ foo: 'bar', value: 123 } as const,
'foo')
If you'd like, go ahead and give this a try in the TypeScript Playground before reading on...
Generic functions
Let's say that we want to write a function which returns its only argument. This is trivially easy in JavaScript:
function identity(value) {
return value
}
However, in TypeScript, this gives us a type error (when noImplicitAny
is
enabled... you're using noImplicitAny
, right?):
// Parameter 'value' implicitly has
// an 'any' type.(7006)
function identity(value) {
return value
}
We could fix the type that the user is going to supply:
function identity(value: number): number {
return value
}
But now this function only works for numbers... Is there a better way?
Yes! We can make this a generic function:
function identity<A>(value: A): A {
return value
}
When usages of identity
are type-checked, TypeScript substitutes A
for the
given type. For example, identity
works for number
:
const value: number = identity<number>(1234)
TypeScript can also infer the type of its argument, so we don't need to specify the type variable:
const value: number = identity(1234)
Back to our challenge...
Now that we know a little more about how generics work, let's see if we can
leverage them to implement getMember
:
function getMember
<T, R extends Record<string, T>>
(record: R, field: keyof R): T {
return record[field]
}
Unfortunately this does not work like we would want it to:
const bar = getMember(
{ foo: 'bar', value: 123 } as const,
'foo')
// bar: 'bar' | 123
The problem here is that R
is typed as Record<string, 'bar' | 123>
, because
T
is typed as 'bar' | 123
in order to cover the type of all values in our
record.
This is a bit of a problem - how are we going to specify a type for each field in our record?
Piped inference
The trick is to leverage type inference here, using a combination of extends
and unknown
:
function getMember
<R extends Record<string | number | symbol, unknown>,
F extends keyof R>
(record: R, field: F): R[F] {
return record[field]
}
We need to type this with enough information for R
to be dereferenced - in
this case, it needs to be a record - and we need to use F extends keyof R
in
order to narrow down the key we're using.
To see why this works, we can expand the type variables (and in this case, we
can get rid of the as const
assertion, but note that as const
will help when
the type variables are inferred):
const bar = getMember<{foo: 'bar', value: 123}, 'foo'>(
{ foo: 'bar', value: 123 }, 'foo')
// bar: 'bar'
I call this technique piped inference - I can't find another name for this in the wild.
More recipes
This technique is pretty powerful, and is useful in all sorts of other situations:
Variadic combiner
This pattern allows us to write a function which takes a variable number of arguments, and combine them in a strongly typed way.
The trick here is to take an argument which extends a non-empty tuple of unknown
values, ie. extends [unknown, ...unknown[]]
.
Let's say we want a function which takes a variadic number of arguments which
may or may not be undefined
, returns undefined
if any argument is
undefined
, or runs a combiner function on the arguments otherwise:
combineNonUndefineds(
'foo' as const,
'bar' as const,
'baz' as const,
(a: 'foo', b: 'bar', c: 'baz') => a + b + c)
We can type combineNonUndefineds
as:
/** Similar to `Exclude`, but can exclude `O` from an entire array */
type ExcludeAll<T extends [unknown, ...unknown[]], O> =
T extends [infer A, ...infer B] ?
B extends [unknown, ...unknown[]] ? [A, ...ExcludeAll<B, O>] : [A, B]
: never
function combineNonUndefineds
<Ts extends [unknown, ...unknown[]], A>
(...args: [
...values: Ts,
combiner: (...values: ExcludeAll<Ts, undefined>) => A
]): A | undefined { /* ... */ }
Takeaways
In general, TypeScript's inference is really powerful. It can be leveraged to make generics more powerful.
The main lesson here is that generics should be used in order to specify just enough information to get the job done, and inference can help by making types based off of generics more specific.
Thanks for reading!
Feel free to reach out if you have any comments to: