How to Use Type Operations and Generics to Avoid Repeating Yourself in the Type Space
Notes from Effective Typescript
Table of contents
- What are Type Operations
- What are Generics
- What is a mapped type and how can it be used to achieve the DRY principle
- What is an identity function and how is it used to achieve DRY
- Instances when 'Repeating Yourself' happens in the type space and how to address them
- 1. Repeating function parameter types
- 2. Repeating function signature types
- 3. Repeating type properties in super sets of types
- 4. Repeating type values in subsets of types
- 5. Repeating type definitions which can be mapped
- 6. Repeating types of tagged unions
- 7. Repeating type properties when making them optional
- 8. Repeating types which exist as values
- 9. Repeating types of return values
- How do we constrain the parameters of generic types as we do the parameters of functions
- Summary
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.