Conditional types are not just for switching behavior based on comparison — they can be used with an infer
keyword to access sub-parts of type information within a larger type
Type inference in conditional types
In the same release where conditional types were added to TypeScript a new infer
keyword was added as well. This keyword, which can only be used in the context of a condition expression (within a conditional type declaration) is an important tool for being able to extract out pieces of type information from other types.
A motivating use case
Let’s consider a practical example: You use a library that provides a well-typed function, but does not expose independent types for the arguments the function takes.
Let’s imagine that there’s a fruit-market
npm package, which only exports a createOrder
function.
tsTry
// @filename: fruit-market.ts////////////////////////////////////////////////////////////////////////////////////// NOT EXPORTED //////////////////////////////////////////////////////////////////////////////////////typeAppleVarieties = 'fuji' | 'gala' | 'honeycrisp' | 'granny smith';typeOrangeVarieties = 'navel' | 'valencia' | 'blood orange' | 'cara cara';typeAllergies = 'peach' | 'kiwi' | 'strawberry' | 'pineapple';typeRipeness = 'green' | 'ripe' | 'overripe';typeQuantityRange = {min : number;max : number;};typeFruitOrderItem <Varieties extends string> = {variety :Varieties ;pricePerUnit : number;quantity : number;totalPrice : number;};typeFruitOrder = {apples :FruitOrderItem <AppleVarieties >[];oranges :FruitOrderItem <OrangeVarieties >[];subtotal : number;salesTax : number;grandTotal : number;};typeFruitOrderPreferences = {apples : {preferredVarieties :AppleVarieties [];avoidSeeds : boolean;organicOnly : boolean;ripeness :Ripeness ;quantity :QuantityRange ;};oranges : {preferredVarieties :OrangeVarieties [];seedlessOnly : boolean;ripeness :Ripeness ;quantity :QuantityRange ;};allergies :Allergies [];prefersLocalProduce : boolean;};//////////////////////////////////////////////////////////////////////////////////////// EXPORTED ////////////////////////////////////////////////////////////////////////////////////////export functioncreateOrder (prefs :FruitOrderPreferences ):FruitOrder {console .log (prefs )return {apples : [],oranges : [],subtotal : 0.00,salesTax : 0.00,grandTotal : 0.00,}}
Look at all that great type information — it’s a shame that none of it is exported!
Our goal is to create a well-typed variable to hold a value of type FruitOrderPreferences
, so we can assemble the right data together, log it, and then pass it to the createOrder
to create that FruitOrder
. A starting point for this code is below. All we need to do is replace GetFirstArg<T> = any
with a more meaningful type expression.
tsTry
// @filename: index.tsimport {createOrder } from './fruit-market';typeGetFirstArg <T > = any;constprefs :GetFirstArg <typeofcreateOrder > = {}createOrder (prefs )
The infer
keyword
The infer
keyword gives us an important tool to solve this problem — it lets us extract and obtain type information from larger types, by capturing pieces of types into a newly-declared type params.
Here’s an example of it in action:
tsTry
/*** If the type `P` passed in is some kind of `PromiseLike<T>`* (where `T` is a new type param), extract `T` and return it.* If `P` is not some subtype of `PromiseLike<any>`, pass the* type `P` straight through and return it*/typeUnwrapPromise <P > =P extendsPromiseLike <inferT > ?T :P ;typetest1 =UnwrapPromise <Promise <string>>typetest2 =UnwrapPromise <Promise <[string[], number[]]>>typetest3 =UnwrapPromise <number>
Here’s a breakdown of what the conditional type means
tsTry
typeUnwrapPromise <P > =P extendsPromiseLike <inferT > ?T :P ;// ---------------------
If P
is a subset of PromiseLike<any>
tsTry
typeUnwrapPromise <P > =P extendsPromiseLike <inferT > ?T :P ;// ---------
Extract the typeParam of PromiseLike<?>
and store it in a new typeParam T
tsTry
typeUnwrapPromise <P > =P extendsPromiseLike <inferT > ?T :P ;// ---
And then return type T
tsTry
typeUnwrapPromise <P > =P extendsPromiseLike <inferT > ?T :P ;// ---
Otherwise return the original typeParam P
Let’s go back to our need to define GetFirstArg<T>
from our fruit-market
library.
First, let’s make sure the condition in the conditional type works the way we want it to, allowing us to return one type if the typeParam looks like a function with at least one argument, and another (never
) otherwise. We’ll begin with the type for functions that have at least one argument, and make the type of that argument generic since we know we’ll want to extract it in a future step.
tsTry
typeOneArgFn <A = any> = (firstArg :A , ..._args : any[]) => void
I’m using the variable name _args
starting with an underscore (_
) here to indicate that I don’t care about any arguments after the first one, but I’m happy to tolerate their presence and ignore them.
Now let’s use a conditional type and a test function to make sure we’re returning never
in the right case, and something other than never
(I’m using string[]
temporarily) when we have function with at least one argument. Remember that the never
is advisable here because it effectively erases incompatible aspects of the type, in the case of a union type, just as we saw in Extract<T>
and Exclude<T>
.
tsTry
typeOneArgFn <A = any> = (firstArg :A , ..._args : any[]) => voidtypeGetFirstArg <T extendsOneArgFn >=T extendsOneArgFn ? string[]: never;// Test casefunctionfoo (x : string,y : number) {return null}// Should be string[]typet1 =GetFirstArg <typeoffoo >
Next, let’s bring in the infer
keyword, and the type param it creates on the fly
tsTry
typeOneArgFn <A = any> = (firstArg :A , ..._args : any[]) => voidtypeGetFirstArg <T >=T extendsOneArgFn <inferR >?R : never;// Test casefunctionfoo (x : string,y : number) {return null}typet1 =GetFirstArg <typeoffoo >
There we go! string
is what we were looking for! Let’s bring it back to our fruit market example
tsTry
// @filename: node_modules/fruit-market.tstypeAppleVarieties = 'fuji' | 'gala' | 'honeycrisp' | 'granny smith';typeOrangeVarieties = 'navel' | 'valencia' | 'blood orange' | 'cara cara';typeAllergies = 'peach' | 'kiwi' | 'strawberry' | 'pineapple';typeRipeness = 'green' | 'ripe' | 'overripe';typeQuantityRange = {min : number;max : number;};Type '{}' is missing the following properties from type 'FruitOrderPreferences': apples, oranges, allergies, prefersLocalProduce2739Type '{}' is missing the following properties from type 'FruitOrderPreferences': apples, oranges, allergies, prefersLocalProducetypeFruitOrderItem <Varieties extends string> = {variety :Varieties ;pricePerUnit : number;quantity : number;totalPrice : number;};typeFruitOrder = {apples :FruitOrderItem <AppleVarieties >[];oranges :FruitOrderItem <OrangeVarieties >[];subtotal : number;salesTax : number;grandTotal : number;};typeFruitOrderPreferences = {apples : {preferredVarieties :AppleVarieties [];avoidSeeds : boolean;organicOnly : boolean;ripeness :Ripeness ;quantity :QuantityRange ;};oranges : {preferredVarieties :OrangeVarieties [];seedlessOnly : boolean;ripeness :Ripeness ;quantity :QuantityRange ;};allergies :Allergies [];prefersLocalProduce : boolean;};export functioncreateOrder (prefs :FruitOrderPreferences ):FruitOrder {console .log (prefs )return {apples : [],oranges : [],subtotal : 0.00,salesTax : 0.00,grandTotal : 0.00,}}// @filename: index.tsimport {createOrder } from 'fruit-market';typeOneArgFn <A extends {}> = (firstArg :A , ..._ : any[]) => voidtypeGetFirstArg <T >=T extendsOneArgFn <inferR >?R : never;constprefs :GetFirstArg <typeofcreateOrder > = {}createOrder (prefs )
Awesome! We’re getting an error that indicates we have the desired type!
Constraints on infer
TypeScript 5 allows type param constraints to be expressed on inferred type params. For example, what if we wanted to extract the first element of a tuple, but only if it’s a subtype of string
Without any kind of constraint, we’re just getting the first element of the tuple, no matter what it is
tsTry
typeGetFirstStringIshElement <T > =T extends readonly [inferS ,..._ :any[]] ?S : neverconstt1 = ["success", 2, 1, 4] asconst constt2 = [4, 54, 5] asconst letfirstT1 :GetFirstStringIshElement <typeoft1 >letfirstT2 :GetFirstStringIshElement <typeoft2 >
if we add a constraint
diff
+ infer S extends string,- infer S,
we get the desired result, with firstT2
evaluating to never
tsTry
typeGetFirstStringIshElement <T > =T extends readonly [inferS extends string,..._ :any[]] ?S : neverconstt1 = ["success", 2, 1, 4] asconst constt2 = [4, 54, 5] asconst letfirstT1 :GetFirstStringIshElement <typeoft1 >letfirstT2 :GetFirstStringIshElement <typeoft2 >
Ok, we’re only extracting the type of the first element of the tuple in the event that the first element (S
) is some subtype of string
, but it’s not great that we see that never
. Really this should be an error, and we can make it an error via the use of a type param constraint.
diff
- type GetFirstStringIshElement<T>+ type GetFirstStringIshElement<T extends readonly [string, ...any[]]>
And now we’ll get a compile error
tsTry
typeGetFirstStringIshElement <T extends readonly [string, ...any[]]> =T extends readonly [inferS extends string,..._ :any[]] ?S : neverconstt1 = ["success", 2, 1, 4] asconst constt2 = [4, 54, 5] asconst letfirstT1 :GetFirstStringIshElement <typeoft1 >letType 'readonly [4, 54, 5]' does not satisfy the constraint 'readonly [string, ...any[]]'. Type at position 0 in source is not compatible with type at position 0 in target. Type 'number' is not assignable to type 'string'.2344Type 'readonly [4, 54, 5]' does not satisfy the constraint 'readonly [string, ...any[]]'. Type at position 0 in source is not compatible with type at position 0 in target. Type 'number' is not assignable to type 'string'.firstT2 :GetFirstStringIshElement <typeoft2 >
This may feel a little redundant, but it’s important to realize that the condition on the conditional type, and the constraint on the type param serve two different purposes.
- The type param constraint describes what is allowed for
T
. Anything that doesn’t align with the constraint will cause a compiler error - The condition in the conditional type is sort of an equivalent to control flow for types. It will never generate a compile error, because it’s essentially just an
if
/else
Utility types that use infer
TypeScript includes a number of utility types, which are kind of like a type-based standard library. A couple of these are essentially just based around generics, conditional types and the infer
keyword. Let’s take a close look at them
Parameters<T>
This is very similar to the GetFirstArg<T>
type we created, but more generalized
ts
/*** Obtain the parameters of a function type in a tuple*/type Parameters/*** The typeParam passed in, must be some subtype of a call signature,* which can take any number of arguments of any types, and can* have any return type*/<T extends (...args: any) => any>/*** As long as `T` matches a call signature, capture all of the args* (as a ...rest) parameter in a new tuple typeParam `P`*/= T extends (...args: infer P) => any? P // and then return the tuple: never; // or return never, if the condition is not matched
ConstructorParameters<T>
This is very similar to the Parameters<T>
but for construct signatures instead of call signatures
ts
/*** Obtain the parameters of a constructor function type in a tuple*/type ConstructorParameters/*** The typeParam passed in, must be some subtype of a construct* signature.** The `abstract` keyword lets this also work with abstract classes,* which can potentially have an `abstract` constructor*/<T extends abstract new (...args: any) => any>/*** As long as `T` matches a construct signature, capture all of the* args (as a ...rest) parameter in a new tuple typeParam `P`*/= T extends abstract new (...args: infer P) => any? P // and then return the tuple: never; // or return never, if the condition is not matched
ReturnType<T>
This utility type captures the return type of a call signature
ts
/*** Obtain the return type of a function type*/type ReturnType/*** The typeParam passed in must be some subtype of a call signature*/<T extends (...args: any) => any>/*** As long as `T` matches the call signature, capture the return type* in a new typeParam `R`*/= T extends (...args: any) => infer R? R // and then return it: any; // otherwise return any
InstanceType<T>
Very similar to ReturnType<T>
, this utility type takes a type with a construct signature, and extracts the type it instantiates. As is the case with ConstructorParameters<T>
, we’re essentially just inserting a few abstract new
keywords
ts
/*** Obtain the return type of a constructor function type*/type InstanceType/*** The typeParam passed in must be some subtype of a construct signature*/<T extends abstract new (...args: any) => any>/*** As long as `T` matches the construct signature, capture the return* type in a new typeParam `R`*/= T extends abstract new (...args: any) => infer R? R // and then return it: any; // otherwise return any
ThisParameterType<T>
and OmitThisParameter<T>
As long as you know what a this
type is, ThisParameterType<T>
follows the last few examples so closely, that it probably doesn’t need much explanation.
ts
/*** Extracts the type of the 'this' parameter of a function type, or 'unknown'* if the function type has no 'this' parameter.*/type ThisParameterType<T>= T extends (this: infer U, ...args: never) => any? U: unknown;
OmitThisParameter<T>
is another story. It involves multiple conditional types and multiple infer
s. Let’s break it down so that we can understand how it works
ts
/*** Removes the 'this' parameter from a function type.*/type OmitThisParameter<T>/*** If `ThisParameterType<T>` evaluates to `unknown`, it means one of two* things:* (1) `T` is not a call signature type* (2) `T` is a call signature type, with a `this` type of `undefined`** In either of these cases, we effectively short circuit, and return* the `unknown`*/= unknown extends ThisParameterType<T>? T/*** In this branch, we know that `T` is a call signature, with a* non-undefined `this` type** Here we are inferring _both_ the tuple type representing the* arguments, _and_ the return type into two new typeParams, `A` and* `R`, respectively*/: T extends (...args: infer A) => infer R/*** Here, we are effectively reconstructing the function* _without_* the `this` type, using both of our `infer`red typeParams, `A`* and `R`*/? (...args: A) => R/*** essentially this is an unreachable branch. It doesn't really* matter what this type is*/: T;