The article covers more advanced topics related to TypeScript. Make sure you know at least the basics.
Inferring Iterables With TypeScript
When writing library code, sometimes you must use advanced TypeScript to achieve the best possible developer experience for other devs. This often involves creating interesting and complex type definitions. If you've struggled with questions like:
- How to infer a specific tuple elements?
- How to infer a specific function arguments?
- How to map them later to a different type structure?
There's good news: this article is for you. Let's dive in.
To learn more about tuples, I recommend the following article: Ugly Relationship Between Tuples In TypeScript And JavaScript.
The Use Case Example
Imagine we're implementing a new version of Redux (a lighter one). You create an actions
object and then, you can dispatch
these actions in any part of your application without needing to import the function itself. This results in very low coupling.
Hereβs how it looks in code:
// @@@ Legend
// π Works and TypeScript is calm.
// π’ TypeScript yields an error.
// Configuration
const store = new Store({
add: (payload: { name: string }) => {
// Logic...
},
remove: (payload: { name: string; description: string }) => {
// Logic...
},
delete: (payload: number) => {
// Logic...
},
});
export const dispatch = store.dispatch;
// Usage
import { dispatch } from 'store';
store.dispatch('add', { name: 'Tom' }); // π
store.dispatch('add', { description: 'Tom' }); // π’ Missing "name".
We've completely changed how we handle our logic. Instead of using direct functions, we have one generic function called dispatch
. It always requires a type
as the first argument and a payload
as the second argument, which corresponds to the argument specified in each function inside the Store
configuration object.
This is the perfect use case for providing a dynamic inference mechanism. By this, I mean something like the following:
- The function is defined in the object as a key-value pair.
- Our mechanism scans for the function signature.
- It picks the key of the function and its first argument.
- Then, the type mapping is performed, and TypeScript will know the parameters for that particular key.
And this is how it may look in your IDE:
Interested? Keep reading! We'll explore this concept thoroughly, step by step.
Understanding Parameters Utility Type
Firstly, we need something fancy to extract the type of parameters passed to a function. Fortunately, starting from TypeScript version 3.1
, we have the Parameters<TFunction>
utility type. It allows us to infer any function's arguments (the function arguments are arrays) and create a tuple from them. Here is an example:
function fn(a: string, b: number, c: boolean) {}
type ParamsAsTuple = Parameters<typeof fn>; // [string, number, boolean].
TypeScript tuples are extremely weird when connected with JavaScript. To learn more, go to the Ugly Relationship Between Tuples in TypeScript and JavaScript article.
What is interesting is that if we indicate an unknown number of elements at the end, it will be handled as well. In addition, when assigning the data for such a tuple, we need to add at least 3 elements at the beginning that match the defined types, but the symbol[]
may be ignored.
function fn(a: string, b: number, c: boolean, ...args: symbol[]) {}
type ParamsAsTuple = Parameters<typeof fn>; // [string, number, boolean, ...symbol[]].
const tuple: ParamsAsTuple = [``, 1, true]; // π.
const tuple1: ParamsAsTuple = [``, 1, true, Symbol(`ds`)]; // π.
const tuple2: ParamsAsTuple = [``, 1]; // π’
Inferring Function Arguments By Index
Function arguments are iterrable. They're stored as an array of "n" elements. So, let's say we want to infer the first, second, and last argument of the 4 arguments function. We may craft an ParametersAt
utility type, that will achieve such goal.
type ParameterAt<T extends (...args: any[]) => any, I extends number> = Parameters<T>[I];
And here you've use case:
function fn(...args: [string, number, boolean]): void { }
function otherFn(a: number, b: string, c: object, d: { XD: true }) { }
type FirstParam = ParameterAt<typeof fn, 0>; // string
type SecondParam = ParameterAt<typeof fn, 1>; // number
type ThirdParam = ParameterAt<typeof fn, 2>; // boolean
type LastParam = ParameterAt<typeof otherFn, 3>; // { XD: true }
What happens here is we're just passing a function type, then Parameters
inside converts arguments to a tuple, and we're extracting a type using the [I]
syntax. It means picking the type by a specific index
.
Inferring First And Last Arguments
The type we crafted before works well if we know the function signature from the start. But what if it's passed as a callback to another function that will perform some operations only if specific argument shapes are defined? We need something more "dynamic" and less work-intensive if the author of a function changes the number of arguments or their signature.
type FirstParameter<T> = T extends (arg1: infer P, ...args: any[]) => any
? P
: never;
type SecondParameter<T> = T extends (
arg1: any,
arg2: infer P,
...args: any[]
) => any
? P
: never;
type LastParameter<T extends (...args: any) => any> = Parameters<T> extends [
...infer _,
infer L,
]
? L
: never;
Here you've the use cases:
const func = (a: number, b: string): void => {};
type FirstParam = FirstParameter<typeof func>; // FirstParam is number
const func = (a: number, b: string, c: boolean): void => {};
type SecondParam = SecondParameter<typeof func>; // SecondParam is string
const func = (a: number, b: string, c: boolean): void => {};
type LastParam = LastParameter<typeof func>; // LastParam is boolean
The infer
keyword acts as a placeholder for a type. When you define a type (such as an array of elements), you can use infer [Name]
as a slot for this particular type and then use it in any way you want to calculate the desired type structure.
Here are some use cases to understand it better:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
type Ret = ReturnType<() => string>; // Ret is string
type ElementType<T> = T extends (infer U)[] ? U : never;
type Elem = ElementType<number[]>; // Elem is number
type Awaited<T> = T extends Promise<infer U> ? U : T;
type Resolved = Awaited<Promise<boolean>>; // Resolved is boolean
Mapped Types Glued With Iterables Inference
Now we have everything we need to implement the behavior you saw at the beginning - the ability to scan function signatures, remember given parameters, and call dispatch("type", "payload_by_key")
.
type FirstParameter<T> = T extends (arg1: infer P, ...args: any[]) => any
? P
: never;
type ConsumersBase = Record<string, (payload: any) => any>;
class Store<TConsumers extends ConsumersBase> {
constructor(private consumers: TConsumers) {}
dispatch = <TType extends keyof TConsumers>(
type: TType,
payload: FirstParameter<TConsumers[TType]>,
): ReturnType<TConsumers[TType]> => {
const result = this.consumers[type](payload);
return result;
};
}
const store = new Store({
'[LOGGING]/sayHi': () => {
console.log(`Hi`);
},
'[COUNTER]/increment': (counter: number): number => {
return counter + 1;
},
});
const counter = store.dispatch(`[COUNTER]/increment`, 1);
console.log(counter); // 2
store.dispatch(`[LOGGING]/sayHi`, undefined); // Logs "Hi"
With the FirstParameter
utility type we crafted before, we infer the type of the first function parameter. Then, the dispatch
function takes a type
, which is a key of objects, remembers it, and as a payload, we read the function's first parameter. At the end, with the built-in ReturnType
, which works the same as Parameters
but for the function return values, we're able to "remember" what the function needs to return too.
Here is how it behaves currently:
After providing a type
that is assigned to the store configuration object, the type of payload specified in the function is automatically recognized. This is the mechanism implemented in Redux itself and many other state management libraries.
Summary
Now you know how to infer types dynamically or statically by providing a specific index for iterable types in TypeScript. This can be a simple tuple or a function with a dedicated number of arguments.
You can map it through a completely different structure with a mapped type and, in addition, remember the original signatures of payloads. All of this guarantees a much-improved developer experience.