Building type utils to derive interfaces from unions of interfaces in TypeScript

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 access A[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, if A[Value] does not exist, we represent that as never.

Full implementation

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.

Full implementation

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!