DEV Community

Cover image for Deriving types from other types in TypeScript — unions and interfaces
Maciej Smoliński
Maciej Smoliński

Posted on • Originally published at notes.maciejsmolinski.com

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.

The transition from union type to interface, and back

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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: Live version of the code

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

Code from this post in action on CodeSandbox

Code from this post in action on CodeSandbox

Top comments (0)