Deriving types from other types in TypeScript — unions and interfaces

Maintaining a lot of similar types can be a mundane task, especially if some of them must stay in sync due to some dependency on one another.

You're going to learn how to, with a little bit of type-level programming, reduce that toil by deriving types from other types.

More specifically, I'm going to show how to, at type-level, turn union type into an interface, as well turn an interface into union type, and where that could be useful.

Union type to interface transition
The transition from union type to interface, and back

💡
This post is going to show the practical use of mapped types, key remapping, indexed access, and conditional types from TypeScript

Use Case

Imagine we're building a simple text editor. We're going to keep track of its contents as well as the current selection, which could come in handy in the future if we decided to, for example, introduce text formatting support.

We can represent these requirements with the following interface:

interface EditorState {
  content: string;
  selection: [number, number] | null;
}

State transitions

In terms of interactions, we want to start with three basic cases:

  • Storing and updating the content
  • Storing and updating the active selection
  • Resetting the state of the editor

Our application is going to dispatch a message, often referred to as action, for each interaction type.

In order to distinguish one action from another, each message is going to include a type field, as well as, optionally, a payload field carrying extra information related to the message.

type Action
  = { type: 'reset' }
  | { type: 'setValue', payload: string }
  | { type: 'setSelection', payload: [number, number] | null };

💡
Why union type instead of interface?

Another option would be to create an interface with a type field defined as 'reset' | 'setValue' | 'setSelection' , and an optional payload field accepting any type.

However, with such a generic type we would lose a lot guarantees, and the type checker wouldn't complain about, for example, a reset action with extra payload, or a setValue action carrying numeric payload.

Current and future state

Reducer, a term often used by state management libraries, is nothing else than a regular function that accepts the current state, the action, and produces a new state.

With precise definitions of state and actions, we implement a simple reducer for the text editor returning different states for each of the actions:

function reducer(state: EditorState, action: Action): EditorState {
  switch (action.type) {
    case 'reset':
      return { ...state, content: '', selection: null };
    case 'setValue':
      return { ...state, content: action.payload };
    case 'setSelection':
      return { ...state, selection: action.payload };
    default:
      return state;
  }
}

Putting things together

Next, we'd like to expose a way of dispatching actions from the UI, so that we can actually modify the state. We're going to use React and the useReducer to keep the code nice and short.

import { useReducer } from 'react';

// ...

function Editor() {
  const initialState = { content: '', selection: null };
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div className="editor">
      <div className="editor__menu">
        <button>Reset</button>
      </div>
      <div className="editor__input">
        <textarea></textarea>
      </div>
    </div>
  );
}

The dispatch function is going to accept an Action as a parameter, and cause the state of the editor to update:

dispatch({ type: 'reset' });
// state becomes { content: '', selection: null }

dispatch({ type: 'setValue', payload: 'Some text' });
// state becomes { content: 'Some text', selection: null }

dispatch({ type: 'setSelection', payload: [3, 8] });
// state becomes { content: 'Some text', selection: [3, 8] }

We can use this knowledge to add behavior to the component we've previously built.

- <button>Reset</button>
+ <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
- <textarea></textarea>
+ <textarea
+   value={state.content}
+   onChange={({ target }) => {
+     const element = target as HTMLTextAreaElement;
+ 
+     dispatch({ 
+       type: 'setValue',
+       payload: element.value || ''
+     });
+   }}
+   onSelect={({ target }) => {
+     const element = target as HTMLTextAreaElement;
+     const { selectionStart, selectionEnd } = element;
+ 
+     dispatch({
+       type: 'setSelection',
+       payload: [selectionStart, selectionEnd],
+     });
+   }}
+ ></textarea>

Dispatcher Interface

It would be convenient to have a set of methods allowing us to build and dispatch these actions more easily, for example:

interface Dispatcher {
  reset: () => void;
  setValue: (payload: string) => void;
  setSelection: (payload: [number, number] | null) => void;
}

Besides hiding implementation details, we would also save a bunch of keystrokes, let's take setting selection as an example:

  <textarea
    ...
    onSelect={({ target }) => {
      const element = target as HTMLTextAreaElement;
      const { selectionStart, selectionEnd } = element;
  
-     dispatch({
-       type: 'setSelection',
-       payload: [selectionStart, selectionEnd],
-     });
+     actions.setSelection([selectionStart, selectionEnd]);
    }}
  ></textarea>

Limitations of the hand-written types

Unfortunately, creating the Dispatcher type by hand can be error-prone and introduce a bit of toil.

What if we decide to add a new action?

type Action
  = { type: 'reset' }
  | { type: 'setValue', payload: string }
  | { type: 'setSelection', payload: [number, number] | null }
+ | { type: 'trimWhitespace' }

Dispatcher does not support it out of the box, and therefore we have to expand the type accordingly

interface Dispatcher {
  reset: () => void;
  setValue: (payload: string) => void;
  setSelection: (payload: [number, number] | null) => void;
+ trimWhitespace: () => void
}

Now, what if the signature of one of the actions changes?

type Action
  = { type: 'reset' }
  | { type: 'setValue', payload: string }
  | { type: 'setSelection', payload: [number, number] | null }
- | { type: 'trimWhitespace' }
+ | { type: 'trimWhitespace', payload: 'leading' | 'trailing' | 'both' }

We get no warnings. Dispatcher type is not in sync with the latest changes whatsoever 😢

Evolution with dynamic dispatcher type

To make the code less error-prone and avoid the toil of having to update types every time one of them changes, we can delegate the job of building the Dispatcher type to the type system.

The tools we're going to use are mapped types, key remapping, and conditional types:

type Dispatcher = {
  [Message in Action as Message['type']]: 
    Message extends { payload: any }
    ? (payload: Message['payload']) => void
    : () => void
}

Now, while hovering over the dispatcher type, we're going to see a dynamically generated type from the union type matching our requirements:

┌───────────────────────────────────────────────────────────────────┐
│ type Dispatcher = {                                               │
│   reset: () => void;                                              │
│   setValue: (payload: string) => void;                            │
│   setSelection: (payload: [number, number] | null) => void;       │
│   trimWhitespace: (payload: 'leading'|'trailing'|'both') => void; │
│ }                                                                 │
└───────────────────────────────────────────────────────────────────┘
         ▼
type Dispatcher = {

Goal achieved, we've mapped union type into an interface with callbacks derived from the payload in the original type 🎉

What about the other direction?

Now, what if we started with the dispatcher, and would like to derive a union of possible actions from it?

type Dispatcher = {
  reset: () => void;
  setValue: (payload: string) => void;
  setSelection: (payload: [number, number] | null) => void;
  trimWhitespace: (payload: 'leading' | 'trailing' | 'both') => void;
}

type Action = ?

Let's check the facts — we know the keys of the interface correspond to the type of the action, and we also know the parameter of the function, when present, defines the type of the payload.

This is enough information to derive the union from the interface, let's see:

type Action = {
  [Key in keyof Dispatcher]: 
    Dispatcher[Key] extends () => void
    ? { type: Key }
    : { type: Key, payload: Parameters<Dispatcher[Key]>[0] }
}[keyof Dispatcher]

Now, let's hover over the dynamically computed Action type to see if it matches our expectations:

┌─────────────────────────────────────────────────────────────────────┐
│ type Dispatcher =                                                   │
│  | { type: "reset" }                                                │
│  | { type: "setValue"; payload: string }                            │ 
│  | { type: "setSelection"; payload: [number, number] | null }       │
│  | { type: "trimWhitespace"; payload: 'leading'|'trailing'|'both' } │
│ }                                                                   │
└─────────────────────────────────────────────────────────────────────┘
         ▼
type Action = {

It indeed satisfies our requirements 🎉

Summary

We've seen examples of how to transform a union type into an interface, as well as how an interface can be turned into a union type.

Type-level programming in TypeScript feels like a superpower, with a bit of use of mapped types and conditional types we are able to derive new types from existing types.

That's what we do every day when we work with values while programming, we process the values, we transform them, and we compute new values from other values.

I hope the example I used for presenting the transformation gave you a clearer picture of some of the extra capabilities of TypeScript.

I purposefully refrained from walking the reader line by line through the code examples. I believe some of these concepts can be better learned by examining the behavior of the code in a live environment. Therefore, I leave this part of the study as an exercise for the reader.

I promised you're going to learn how to turn union types into interfaces, and back, and explain where that could be useful, and I hope I delivered.

Please let me know your thoughts!

Bonus: Complete code listing

You can find a live version of this code on CodeSandbox:

Code from this post in action on CodeSandbox

I am also posting the entire code listing for reference below. It shows how to derive an interface from a union type but with little effort, by following the instructions from this post, it could be adapted to make the transformation the other way around.

import React, { useReducer } from 'react';

interface EditorState {
  content: string;
  selection: [number, number] | null;
}

type Action =
  | { type: 'reset' }
  | { type: 'setValue'; payload: string }
  | { type: 'setSelection'; payload: [number, number] | null }
  | { type: 'trimWhitespace'; payload: 'leading' | 'trailing' | 'both' };

function reducer(state: EditorState, action: Action): EditorState {
  switch (action.type) {
    case 'reset':
      return { ...state, content: '', selection: null };
    case 'setValue':
      return { ...state, content: action.payload };
    case 'setSelection':
      return { ...state, selection: action.payload };
    case 'trimWhitespace': {
      switch (action.payload) {
        case 'leading':
          return { ...state, content: state.content.replace(/^\s+/, '') };
        case 'trailing':
          return { ...state, content: state.content.replace(/\s+$/, '') };
        case 'both':
          return { ...state, content: state.content.trim() };
        default:
          return state;
      }
    }
    default:
      return state;
  }
}

type Dispatcher = {
  [Message in Action as Message['type']]: Message extends { payload: any }
    ? (payload: Message['payload']) => void
    : () => void;
};

function getActions(dispatch: React.Dispatch<Action>): Dispatcher {
  return {
    reset: () => dispatch({ type: 'reset' }),
    setValue: (payload: string) => dispatch({ type: 'setValue', payload }),
    setSelection: (payload: [number, number] | null) => dispatch({ type: 'setSelection', payload }),
    trimWhitespace: (payload: 'leading' | 'trailing' | 'both') => dispatch({ type: 'trimWhitespace', payload }),
  };
}

export default function Editor() {
  const initialState = { content: '', selection: null };
  const [state, dispatch] = useReducer(reducer, initialState);
  const actions = getActions(dispatch);

  return (
    <div className="editor">
      <div className="editor__menu">
        <button onClick={() => actions.reset()}>Reset</button>
        <button onClick={() => actions.trimWhitespace('both')}>Trim Whitespace</button>
      </div>
      <div className="editor__input">
        <textarea
          value={state.content}
          onChange={({ target }) => {
            const element = target as HTMLTextAreaElement;

            actions.setValue(element.value || '');
          }}
          onSelect={({ target }) => {
            const element = target as HTMLTextAreaElement;
            const { selectionStart, selectionEnd } = element;

            actions.setSelection([selectionStart, selectionEnd]);
          }}
        ></textarea>
      </div>
    </div>
  );
}