In this post, we're going to build small, generic type utils in TypeScript.
To work with a real use case, we're going to transform a non-generic code from my previous post into a generic type utility, that could work with more input types, and thus be more reusable.
type Dispatcher = {
[Message in Action as Message['type']]: Message extends { payload: any }
? (payload: Message['payload']) => void
: () => void;
};
Non-generic code, hard to re-use ⤴
There are many ways to achieve this goal. We're going to pick a fancy one, leveraging template literals, and type inference to provide a nice development experience:
type Dispatcher =
ValuesAsCallbacks<UnionToKeyValue<Action, 'type:payload'>>;
Reusable, generic type utilities ⤴
Now, let's see how to build that 🤔
A two-step process
First, let's break down the process of turning one type into another. We'll look into the implementation and more detailed explanation in the next section.
1️⃣ Union to Key-Value
First, we're turning union into a key-value interface by providing a colon-delimited string literal, defining the property to be used as a key and the property to be used as a value. In case the property is missing on an item from the union, we're going to represent this fact with the never
type:
type Action =
| { type: 'reset' }
| { type: 'setValue'; payload: string }
| { type: 'setSelection'; payload: [number, number] | null }
| { type: 'trimWhitespace'; payload: 'leading'|'trailing'|'both' };
type IntermediateResult = UnionToKeyValue<Action, 'type:payload'>;
▲
┌───────────────────────────────────────────────────────────────────┐
│ type IntermediateResult = { │
│ reset: never; │
│ setValue: string; │
│ setSelection: [number, number] | null; │
│ trimWhitespace: 'leading'|'trailing'|'both'; │
│ } │
└───────────────────────────────────────────────────────────────────┘
Can you already see how that is going to turn into an interface with callbacks?
2️⃣ Values As Callbacks
Second, we're transforming that key-value interface into key-callback interface, where the callback function in the latter is accepting a parameter of the same type as the value in the former:
type IntermediateResult = {
reset: never;
setValue: string;
setSelection: [number, number] | null;
trimWhitespace: 'leading'|'trailing'|'both';
}
type Dispatcher = ValuesAsCallbacks<IntermediateResult>;
▲
┌───────────────────────────────────────────────────────────────────┐
│ type Dispatcher = { │
│ reset: () => void; │
│ setValue: (payload: string) => void; │
│ setSelection: (payload: [number, number] | null) => void; │
│ trimWhitespace: (payload: 'leading'|'trailing'|'both') => void; │
│ } │
└───────────────────────────────────────────────────────────────────┘
Implementation, and a bit of explanation
Union to Key-Value
Let's take a look at how to implement the first utility type 🕵️
type UnionToKeyValue<
T extends { [key: string]: any }, ①
K extends string ②
> = ...
(1) The util accepts an interface, or a union of interfaces, indexed by keys being strings or string literals as a first parameter.
(2) It also requires a second parameter to be a string, or a string literal.
... = K extends `${infer Key}:${infer Value}` ③
? Key extends keyof T ④
? ...
: never
: never;
(3) Key and Value never appeared as a type parameter of the util. Thanks to template literal types, we can infer both from the type provided as the second parameter, so long as it's a string or a string literal, and it contains a colon somewhere inside.
Given
'type:payload'
provided as a second parameter, we're going to infer that Key is'type'
and Value is'payload'
.If we don't find a colon in the second parameter, we fail to pattern-match and we return
never
.(4) Next, with the inferred Key, we double-check it belongs to the key set of the first parameter, T — an interface, or a union of interfaces. If it does not belong there, we return
never
.
? { [A in T as A[Key]]: Value extends keyof A ? A[Value] : never } ⑤
...
(5) Now, we use mapped types to iterate through T, which implies T must be a union of types rather than a single interface to make mapping possible. With an interface, we'd see the use of
keyof
there.The use of
as
combined with indexed accessA[Key]
also gives a clue about elements of the union being interfaces and not primitive types such as undefined or null.Finally, if
A[Key]
does not exist in the element, we're not going to include it in the output. We're not that strict with the Value, ifA[Value]
does not exist, we represent that asnever
.
Final code
type UnionToKeyValue<
T extends { [key: string]: any },
K extends string
> = K extends `${infer Key}:${infer Value}`
? Key extends keyof T
? { [A in T as A[Key]]: Value extends keyof A ? A[Value] : never }
: never
: never;
That's most of the heavy work done!
Values as Callbacks
Going from interfaces to interfaces should be slightly easier, let's take a look.
type ValuesAsCallbacks<T extends { [key: string]: any }> = ... ①
(1) This util accepts only one parameter, that must represent an interface with keys being strings or string literals and no constraints on the values.
... = {
[K in keyof T]: T[K] extends never ②
? () => void ③
: (payload: T[K]) => void; ④
};
(2) T is an interface, we iterate through all keys of the provided type, represented as K. Now, we check if the value, T[K], has type
never
.(3) If we detect the value is of the type
never
, we return a type representing a callback not accepting any parameters.(4) Otherwise, we return a type representing a single-parameter callback, where the parameter has the same type as the value in the original interface.
Final code
type ValuesAsCallbacks<T extends { [key: string]: any }> = {
[K in keyof T]: T[K] extends never
? () => void
: (payload: T[K]) => void;
};
That was it, the last missing piece in the puzzle of turning the union of interfaces into an interface with callbacks 🎉 We made it!
Complete Code
The complete code listing from this post can be found under this link:
Reusable types from this post in TypeScript playground ⤴
Final words
Now that you've explored one approach, I would encourage you to try other approaches, for example, splitting UnionAsKeyValue<T, K>
into two separate utils and see how you find it.
Good luck!
Top comments (0)