DEV Community

Cover image for Building type utils - deriving interfaces from union types in TypeScript
Maciej Smoliński
Maciej Smoliński

Posted on • Edited on • Originally published at notes.maciejsmolinski.com

Building type utils - deriving interfaces from union types 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;
};
Enter fullscreen mode Exit fullscreen mode

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'>>;
Enter fullscreen mode Exit fullscreen mode

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';                    
 }                                                                 
└───────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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; 
 }                                                                 
└───────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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  
> = ...
Enter fullscreen mode Exit fullscreen mode

(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;
Enter fullscreen mode Exit fullscreen mode

(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 }  
...
Enter fullscreen mode Exit fullscreen mode

(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.

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;
Enter fullscreen mode Exit fullscreen mode

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 }> = ...  
Enter fullscreen mode Exit fullscreen mode

(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;  
};
Enter fullscreen mode Exit fullscreen mode

(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;
};
Enter fullscreen mode Exit fullscreen mode

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:

type-utils-union-to-key-value--values-as-callbacks

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)