Nnadozie Okeke
dozie.dev

dozie.dev

How to Use Type Operations and Generics to Avoid Repeating Yourself in the Type Space

Notes from Effective Typescript

Nnadozie Okeke's photo
Nnadozie Okeke
·Jan 17, 2022·

7 min read

Subscribe to my newsletter and never miss my upcoming articles

Table of contents

What are Type Operations

We're familiar with popular Javascript operators such as + - += && ||[1]. Similar operators exist for Types in Typescript[2]. Examples are keyof, in, typeof. By using Typescript operators, we can express types in simple and short ways. For example we can condense

type list = {
 first: string,
 second: string,
 third: string
}

to become

type list = {
 [param in 'first' | 'second' | 'third']: string
}

What are Generics

We're familiar with the concept of parameters in Javascript, such as p in the following function signature function takesParam(p). Typescript has a similar concept which allows for passing types as parameters, called generics [3] , such as in the following signature function takesTypeParam<T>(p: T). Generics can be used to create generic functions as well as generic types.

// A generic function
function identity<T>(word: T) {
 return word
}

// A generic type
type genericList<T> = { [n in 'first' | 'second' | 'third']: T };

compared to type list from earlier which could only create an object with parameters of the string type, we can use genericList to create objects with parameters of types not constrained to just string. For example:

let numberList: genericList<number> = {
 first: 1,
 second: 2,
 third: 3
}

let stringList: genericList<string> = {
 first: 'one',
 second: 'two',
 third: 'three'
}

What is a mapped type and how can it be used to achieve the DRY principle

We're familiar with the for loop in Javascript. Mapped types are a type space equivalent for looping over the fields in an iterable object. We saw an example of this is when we declared

type genericList<T> = { [n in 'first' | 'second' | 'third']: T }

Here we loop over 'first' | 'second' | 'third', and assign each a type of T, freeing us of the need to write each type assignment individually.

What is an identity function and how is it used to achieve DRY

We're familiar with an identity function [4] . Given a generic type such as genericList, we typically always need to write the generic parameter when using it in a type declaration, as in:

const stringList: genericList<string> = {
 first: 'one',
 second: 'two',
 third: 'three'
}

However, using a carefully typed identity function we can save ourselves this repetition. For example:

type genericList<T> = { [n in 'first' | 'second' | 'third']: T };

const genericListFunc = <T extends any>(x: genericList<T>) => x

const stringList = genericListFunc({
 first: 'one',
 second: 'two',
 third: 'three'
})

const numberList = genericListFunc({
 first: 1,
 second: 2,
 third: 3
})

Instances when 'Repeating Yourself' happens in the type space and how to address them

1. Repeating function parameter types

Say we have multiple parameters sharing the same type:

function distance(a: {x: number, y: number}, b: {x: number, y: number}) {
  return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
}

create a name for the parameter type and use that:

interface Point2D {
  x: number;
  y: number;
}
function distance(a: Point2D, b: Point2D) { /* ... */ }

2. Repeating function signature types

Say several functions share the same signature

function get(url: string, opts: Options): Promise<Response> { /* ... */ }
function post(url: string, opts: Options): Promise<Response> { /* ... */ }

Then you can factor out a named type for this signature and use typed function expressions instead of function statements:

type HTTPFunction = (url: string, opts: Options) => Promise<Response>;
const get: HTTPFunction = (url, opts) => { /* ... */ };
const post: HTTPFunction = (url, opts) => { /* ... */ };

3. Repeating type properties in super sets of types

Say we have two types, one a superset of the other, as we have here where Person is a superset of PersonWithBirthDate.

interface Person {
  firstName: string;
  lastName: string;
}

interface PersonWithBirthDate {
  firstName: string;
  lastName: string;
  birth: Date;
}

Rather than duplicate properties to add new properties, have one extend the other:

interface Person {
  firstName: string;
  lastName: string;
}

interface PersonWithBirthDate extends Person {
  birth: Date;
}

4. Repeating type values in subsets of types

Here we have TopNavState which is a subset of State.

interface State {
  userId: string;
  pageTitle: string;
  recentFiles: string[];
  pageContents: string;
}
interface TopNavState {
  userId: string;
  pageTitle: string;
  recentFiles: string[];
}

Rather than building up State by extending TopNavState, you’d like to define TopNavState as a subset of the fields in State. This way you can keep a single interface defining the state for the entire app. You can remove duplication in the types of the properties by indexing into State:

type TopNavState = {
  userId: State['userId'];
  pageTitle: State['pageTitle'];
  recentFiles: State['recentFiles'];
};

5. Repeating type definitions which can be mapped

We can go further to write a better TopNavState type using a mapped type.

type TopNavState = {
  [k in 'userId' | 'pageTitle' | 'recentFiles']: State[k]
};

This particular pattern is so common that it’s part of the standard library, where it’s called Pick [5] :

type Pick<T, K extends keyof T> = {
  [k in K]: T[k]
};

Vanderkam, Dan. Effective TypeScript . O'Reilly Media. Kindle Edition.

6. Repeating types of tagged unions

Notice the repeated type values in ActionType.

interface SaveAction {
  type: 'save';
  // ...
}
interface LoadAction {
  type: 'load';
  // ...
}
type Action = SaveAction | LoadAction;
type ActionType = 'save' | 'load';  // Repeated types!

Rather than do this, we can define ActionType without repetition by indexing into the Action union:

type ActionType = Action['type'];  // Type is "save" | "load"

7. Repeating type properties when making them optional

Instead of:

interface Options {
  width: number;
  height: number;
  color: string;
  label: string;
}
interface OptionsUpdate {
  width?: number;
  height?: number;
  color?: string;
  label?: string;
}
class UIWidget {
  constructor(init: Options) { /* ... */ }
  update(options: OptionsUpdate) { /* ... */ }
}

You can construct OptionsUpdate from Options using a mapped type and keyof:

type OptionsUpdate = {[k in keyof Options]?: Options[k]};

This pattern is also extremely common and is included in the standard library as Partial:

class UIWidget {
  constructor(init: Options) { /* ... */ }
  update(options: Partial<Options>) { /* ... */ }
}

8. Repeating types which exist as values

Rather than:

const INIT_OPTIONS = {
  width: 640,
  height: 480,
  color: '#00FF00',
  label: 'VGA',
};
interface Options {
  width: number;
  height: number;
  color: string;
  label: string;
}

We can do the same with typeof:

type Options = typeof INIT_OPTIONS;

9. Repeating types of return values

Given a function such as this:

function getUserInfo(userId: string) {
  // ...
  return {
    userId,
    name,
    age,
    height,
    weight,
    favoriteColor,
  };
}
// Return type inferred as { userId: string; name: string; age: number, ... }

Rather than repeat ourselves to create a named type of the return value:

type UserInfo = { userId: string; name: string; age: number, ... }

We can use the ReturnType utility

type UserInfo = ReturnType<typeof getUserInfo>;

How do we constrain the parameters of generic types as we do the parameters of functions

Generic types are the equivalent of functions for types. And functions are the key to DRY for logic. So it should come as no surprise that generics are the key to DRY for types. But there’s a missing piece to this analogy. -- Vanderkam, Dan. Effective TypeScript . O'Reilly Media. Kindle Edition.

We use the type system to constrain the values we pass to functions. We pass numbers, not objects; we find the area of shapes, not database records.

How do you constrain the parameters passed to a generic type? We do so with extends. We can declare that any generic parameter extends a type. For example:

interface Name {
  first: string;
  last: string;
}
type DancingDuo<T extends Name> = [T, T];

const couple1: DancingDuo<Name> = [
  {first: 'Fred', last: 'Astaire'},
  {first: 'Ginger', last: 'Rogers'}
];  // OK

When we try to pass DancingDuo an incomplete object we get an error because the type we're trying to pass it does not pass our constraint.

const couple2: DancingDuo<{first: string}> = [
                       // ~~~~~~~~~~~~~~~
                       // Property 'last' is missing in type
                       // '{ first: string; }' but required in type 'Name'
  {first: 'Sonny'},
  {first: 'Cher'}
];

{first: string} does not extend Name, hence the error.

Summary

Using type operations and generic types, we're able to avoid repetition in the type space by using common mechanisms for filtering out repeated types.

 
Share this