We saw that, for any given initialValue type, useState() is returning a tuple with a value item of the same type.
1const [value] = useState("hello")
2// `value` is of type `string`
3
Typing useState()
useState() typing with function overloads
We could achieve such behavior by defining overloads for all possible types, as follows:
1interface UseState {
2 (initialValue: string): [ // overload #1
3 value: string,
4 setter: (value: string) => void
5 ]
6 (initialValue: number): [ // overload #2
7 value: number,
8 setter: (value: number) => void
9 ]
10 (initialValue: boolean): [ // overload #3
11 value: boolean,
12 setter: (value: boolean) => void
13 ]
14 // and so on...
15
16 // "catch-all" overloads
17 // overload #4
18 (initialValue: any): [value: any, setter: (value: any) => void]
19}
20
21
22const useState: UseState = (initialValue: any) => {
23 // ...
24}
25
Which could work:
1const [stringValue] = useState('hello')
2
3// `stringValue` is of type `string` đ
4
5
6const [numberValue] = useState(10)
7
8// `numberValue` is of type `number` đ
9
However, what happens if someone is passing to useState() a complex object?
1const [value] = useState({ name: 'John', position: undefined })
2
3// `value` is of type `any` đ¨
4
Here our useState() usage is fall-backing to the "catch-all" overload (overload #4), which assigns the any type to value.
As we learned to use them, function overloads rapidly reach their limits when supplied types are not scalars.
useState() typing with Generics
The real React's useState() is leveraging TypeScript Generics to provide a more flexible and reliable inference of types:
1import { useState } from 'react'
2
3const [value] = useState({ name: 'John', position: undefined })
4
5// `value` is of type `{ name: string; position: undefined; }`
6// (remember TypeScript's object literal inference)
7
8
9interface Person {
10 name: string
11 position?: 'full-time' | 'part-time'
12}
13const [value] = useState<Person>({ name: 'John', position: undefined })
14
15// `value` is now of type `Person`
16
TypeScript Generics is the < > syntax that allows interfaces and functions to receive type parameters.
Let's see our homemade useState() built with generics:
1// `T` is a type argument, that, if not provided
2// will be assigned with the type inferred from `initialValue`
3function useState<T>(initialValue: T) : [
4 value: T,
5 setter: (value: T) => void
6] {
7// ...
8}
9
10// T = Person
11const [value] = useState<Person>({ name: 'John', position: undefined })
12
13// T = { name: string; position: undefined; }
14const [value] = useState({ name: 'John', position: undefined })
15
Our useState() typing is missing an edge-case when initialValue is missing:
1const [value] = useState()
2
To support this use case, we will need to assign a new overload to useState() and a default value to the type parameter (T).
Type parameter default value
A type parameter can have a default value assigned as follows:
1// `T` is an optional type argument
2function useState<T = undefined>() : [
3 value: T | undefined,
4 setter: (value: T) => void
5] {
6// ...
7}
8
9// which allow us to do:
10
11// `T = undefined`, so is `value`'s type
12const [value] = useState()
13
14// `T = undefined | Person`, so is `value`'s type
15const [value] = useState<Person>()
16
Our complete homemade useState() look as follows:
1function useState<T = undefined>() : [
2 value: T | undefined,
3 setter: (value: T) => void
4];
5function useState<T>(initialValue: T) : [
6 value: T,
7 setter: (value: T) => void
8];
9function useState<T>(initialValue?: T): [
10 value: T | undefined,
11 setter: (value: T) => void
12] {
13 return [] as unknown as [any, any]
14}
15
16const [value] = useState()
17// `value` type is `undefined`
18
19const [value] = useState<string>()
20// `value` type is `string | undefined`
21
22const [value] = useState('string')
23// `value` type is `string`
24
Let's now compare how our homemade useState() compares to React's one.
React's useState() typings
Here is React's official useState() typings:
12 * Returns a stateful value, and a function to update it.
3 *
456
7 function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
8
9 // convenience overload when first argument is omitted
10 11 * Returns a stateful value, and a function to update it.
12 *
131415
16 function useState<S = undefined>(): [S | undefined, Dispatch<SetStateAction<S | undefined>>];
17
React useState() typings are combining 2 function overloads with some generic types:
- The first overload describe the typing for a call with a initialValue (here: initialState)
- The last is describing a call without any argument.
âšī¸ Note that the lack of initialState leads to a possibly undefined value type: value: S | undefined
Let's take a close look at the Dispatch<SetStateAction<S>> expression:
1// since Dispatch<T> is also used by useReducer():
2function useReducer<R extends Reducer<any, any>, I>(
3 reducer: R,
4 initializerArg: I & ReducerState<R>,
5 initializer: (arg: I & ReducerState<R>) => ReducerState<R>
6): [ReducerState<R>, Dispatch<ReducerAction<R>>];
7
8// it made sense to extract it in a separate type.
9// Here are Dispatch<T> and SetStateAction<T> definitions:
10
11interface Dispatch<A> { (value: A) => void; }
12
13// we will come back to the `type` keyword in the next section
14type SetStateAction<S> = S | ((prevState: S) => S);
15
16
17// Dispatch<SetStateAction<S>>
18// is equivalent to:
19
20interface Dispatch<S> {
21 (value: S | ((prevState: S) => S)) => void;
22}
23
Note that a Generic type can take another generic type as a parameter, making types more reusable and composable.
More on Generics
TypeScript Generics are helpful to build general purposes functions or types; Let's see other Generics features with examples beyond React's useState()
Constrained type parameters
Some utils functions or even React components need to work with many types of objects or set of objects having a particular set of properties.
Let's look at the following customDateSort() sorting function:
1import { compareDesc } from 'date-fns'
2
3function customDateSort(items) {
4 return items.sort(
5 (a, b) => compareDesc(
6 (a.updatedAt || a.createdAt),
7 (b.updatedAt || b.createdAt)
8 )
9 )
10}
11
customDateSort() expect only to be called with an array of items having at least a createdAt property of type Date (because of compareDesc() signature).
We could type customDateSort() as follows:
1function customDateSort(
2 items: { createdAt: Date, updatedAt?: Date }[]
3) {
4 return items.sort(
5 (a, b) => compareDesc(
6 (a.updatedAt || a.createdAt),
7 (b.updatedAt || b.createdAt)
8 )
9 )
10}
11
However, it won't work as expected:
Using a inline interface is too restrictive for our customDateSort() use-case.
Let's see how we can restrict the items type while keeping it flexible to extraneous properties.
We can provide such restrictions in the customDateSort() types definition by leveraging _constrained type parameter _as follows:
1function customDateSort<T extends { createdAt: Date, updatedAt?: Date }>(
2 items: T[]
3) {
4 return items.sort(
5 (a, b) => compareDesc(
6 (a.updatedAt || a.createdAt),
7 (b.updatedAt || b.createdAt)
8 )
9 )
10}
11
Adding the extends keyword to a type parameter (T) forces it to match the given type, here: { createdAt: Date, updatedAt?: Date }.
In the arguments list, T[] is also a constraint that forces the items argument to be "an array of type T".
Both combined, T extends { createdAt: Date, updatedAt?: Date } and T[], describes the following requirements:
items should be an array of T type which should extend { createdAt: Date, updatedAt?: Date }
Providing an array of items missing the createdAt property will raise an error:
The createdAt property is missing â ī¸
Unlike an inline argument type (customDateSort(items: { createdAt: Date, updatedAt?: Date })), adding a constraint by using extends gives more flexibility to the end-user, as shown below:
All items's inferred properties are assigned to T, providing a great type inference.
The case of array constraints on arguments
Adding an array type constraint to some arguments does not always require the use of extends as shown below:
1// takes any kind of array
2function isNonEmptyArray(array: any[]): boolean {
3 return Array.isArray(array) &&
4 array.length > 0 &&
5 array.every(item => typeof item === 'boolean' || !!item)
6}
7
8isNonEmptyArray(['', null, undefined])
9isNonEmptyArray([1, 2, 3])
10isNonEmptyArray([new Date()])
11isNonEmptyArray([])
12
13// from any type of array, return an array of string
14function onlyStringArray(array: any[]): string[] {
15 return Array.isArray(array) && array.filter((item) => typeof item === 'string'))
16}
17
18onlyStringArray([])
19onlyStringArray([2, '3', 4])
20
Multiple type parameters
A function or interface can take multiple type parameters.
Let's see it in action with the ReactElement<P, T> type:
1interface ReactElement<P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>> {
2 type: T;
3 props: P;
4 key: Key | null;
5}
6
You will notice that:
- a type parameter's name can be different from T but should be in uppercase
- T has some type constraints
Generic type arguments inference
We saw that generics could be used on interfaces and functions as mandatory type parameters or have a default value and also can be constrained to a given "shape."
When it comes to function, type parameters can also be automatically inferred from a function argument, as we saw earlier with useState():
1// `T` is a type argument, that, if not provided
2// will be assigned with the type inferred from `initialValue`
3function useState<T>(initialValue: T) : [
4 value: T,
5 setter: (value: T) => void
6] {
7// ...
8}
9
10// T = Person
11const [value] = useState<Person>({ name: 'John', position: undefined })
12
13// T = { name: string; position: undefined; }
14const [value] = useState({ name: 'John', position: undefined })
15
However, TypeScript function arguments inference is more powerful than just extracting a given argument's type.
When an argument's type is a function, TypeScript is capable of inferring the function's return type, as demonstrated below:
1function arrayMap<T, U>(iterator: (item: T) => U): (a: T[]) => U[] {
2 return (array) => array.map(iterator)
3}
4
5const plusOne = arrayMap((item: number) => item + 1)
6// `plusOne` is of type `(a: number[]) => number[]` â¨
7
8const result = plusOne([1, 2, 3])
9// `result` is of type `number[]` â¨
10
arrayMap() is a function with 2 type arguments:
- T is inferred from the iterator function argument type (item)
- U is inferred from the iterator function return type
How to debug generics
TypeScript errors around generics or function overloads can be a bit cryptic:
Example of a TypeScript error with an overloaded function (Array.reduce())
Example of a TypeScript error on a generic bad usage (1 is not assignable to T)
For this reason, when facing such TypeScript errors, take a step back to think again about the types' definitions to not focus too much on the compile error.