Written by Simohamed Marhraoui ✏️
We’ve all been in situations where we used a library that had been typed sparingly. Take the following third-party function, for example:
function describePerson(person: {
name: string;
age: number;
hobbies: [string, string]; // tuple
}) {
return `${person.name} is ${person.age} years old and love ${person.hobbies.join(" and ")}.`;
}
If the library doesn’t provide a standalone type for the person
argument of describePerson
, defining a variable beforehand as the person
argument would not be inferred correctly by TypeScript.
const alex = {
name: 'Alex',
age: 20,
hobbies: ['walking', 'cooking'] // type string[] != [string, string]
}
describePerson(alex) /* Type string[] is not assignable to type [string, string] */
TypeScript will infer the type of alex
as { name: string; age: number; hobbies: string[] }
and will not permit its use as an argument for describePerson
.
And, even if it did, it would be nice to have type checking on the alex
object itself to have proper autocompletion. We can easily achieve this, thanks to the infer
keyword in TypeScript.
const alex: GetFirstArgumentOfAnyFunction<typeof describePerson> = {
name: "Alex",
age: 20,
hobbies: ["walking", "cooking"],
};
describePerson(alex); /* No TypeScript errors */
The infer
keyword and conditional typing in TypeScript allows us to take a type and isolate any piece of it for later use.
The no-value never
type
In TypeScript, never
is treated as the “no value” type. You will often see it being used as a dead-end type. A union type like string | never
in TypeScript will evaluate to string
, discarding never
.
To understand that, you can think of string
and never
as mathematical sets where string
is a set that holds all string values, and never
is a set that holds no value (∅ set). The union of such two sets is obviously the former alone.
By contrast, the union string | any
evaluates to any
. Again, you can think of this as a union between the string
set and the universal set (U) that holds all sets, which, to no one’s surprise, evaluates to itself.
This explains why never
is used as an escape hatch because, combined with other types, it will disappear.
Using conditional types in TypeScript
Conditional types modify a type based on whether or not it satisfies a certain constraint. It works similarly to ternaries in JavaScript.
The extends
keyword
In TypeScript, constraints are expressed using the extends
keyword. T extends K
means that it’s safe to assume that a value of type T
is also of type K
, e.g., 0 extends number
because var zero: number = 0
is type-safe.
Thus, we can have a generic that checks whether a constraint is met, and return different types.
StringFromType
returns a literal string based on the primitive type it receives:
type StringFromType<T> = T extends string ? 'string' : never
type lorem = StringFromType<'lorem ipsum'> // 'string'
type ten = StringFromType<10> // never
To cover more cases for our StringFromType
generic, we can chain more conditions exactly like nesting ternary operators in JavaScript.
type StringFromType<T> = T extends string
? 'string'
: T extends boolean
? 'boolean'
: T extends Error
? 'error'
: never
type lorem = StringFromType<'lorem ipsum'> // 'string'
type isActive = StringFromType<false> // 'boolean'
type unassignable = StringFromType<TypeError> // 'error'
Conditional types and unions
In the case of extending a union as a constraint, TypeScript will loop over each member of the union and return a union of its own:
type NullableString = string | null | undefined
type NonNullable<T> = T extends null | undefined ? never : T // Built-in type, FYI
type CondUnionType = NonNullable<NullableString> // evalutes to `string`
TypeScript will test the constraint T extends null | undefined
by looping over our union, string | null | undefined
, one type at a time.
You can think of it as the following illustrative code:
type stringLoop = string extends null | undefined ? never : string // string
type nullLoop = null extends null | undefined ? never : null // never
type undefinedLoop = undefined extends null | undefined ? never : undefined // never
type ReturnUnion = stringLoop | nullLoop | undefinedLoop // string
Because ReturnUnion
is a union of string | never | never
, it evaluates to string
(see explanation above.)
You can see how abstracting the extended union into our generic allows us to create the built-in Extract
and Exclude
utility types in TypeScript:
type Extract<T, U> = T extends U ? T : never
type Exclude<T, U> = T extends U ? never : T
Conditional types and functions
To check whether a type extends a certain function shape, the [Function](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/ban-types.md#:~:text=Avoid%20the%20Function,the%20new%20keyword)
type must not be used. Instead, the following signature can be used to extend all possible functions:
type AllFunctions = (…args: any[]) => any
…args: any[]
will cover zero and more arguments, while => any
would cover any return type.
Using infer
in TypeScript
The infer
keyword compliments conditional types and cannot be used outside an extends
clause. Infer
allows us to define a variable within our constraint to be referenced or returned.
Take the built-in TypeScript ReturnType
utility, for example. It takes a function type and gives you its return type:
type a = ReturnType<() => void> // void
type b = ReturnType<() => string | number> // string | number
type c = ReturnType<() => any> // any
It does that by first checking whether your type argument (T
) is a function, and in the process of checking, the return type is made into a variable, infer R
, and returned if the check succeeds:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
As previously mentioned, this is mainly useful for accessing and using types that are not available to us.
React prop types
In React, we often need to access prop types. To do that, React offers a utility type for accessing prop types powered by the infer
keyword called ComponentProps
.
type ComponentProps<
T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>
> = T extends JSXElementConstructor<infer P>
? P
: T extends keyof JSX.IntrinsicElements
? JSX.IntrinsicElements[T]
: {}
After checking that our type argument is a React component, it infers its props and returns them. If that fails, it checks that the type argument is an IntrinsicElements
(div
, button
, etc.) and returns its props. If all fails, it returns {}
which, in TypeScript, means “any non-null value”.
Infer
keyword use cases
Using the infer
keyword is often described as unwrapping a type. Here are some common uses of the infer
keyword.
Function’s first argument:
This is the solution from our first example:
type GetFirstArgumentOfAnyFunction<T> = T extends (
first: infer FirstArgument,
...args: any[]
) => any
? FirstArgument
: never
type t = GetFirstArgumentOfAnyFunction<(name: string, age: number) => void> // string
Function’s second argument:
type GetSecondArgumentOfAnyFunction<T> = T extends (
first: any,
second: infer SecondArgument,
...args: any[]
) => any
? SecondArgument
: never
type t = GetSecondArgumentOfAnyFunction<(name: string, age: number) => void> // number
Promise return type
type PromiseReturnType<T> = T extends Promise<infer Return> ? Return : T
type t = PromiseReturnType<Promise<string>> // string
Array type
type ArrayType<T> = T extends (infer Item)[] ? Item : T
type t = ArrayType<[string, number]> // string | number
Conclusion
The infer
keyword is a powerful tool that allows us to unwrap and store types while working with third-party TypeScript code. In this article, we explained various aspects of writing robust conditional types using the never
keyword, extends
keyword, unions, and function signatures.
Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.
TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.
Top comments (0)