Chapter 4. Generics

Until now, our main goal was to take the inherent flexibility of JavaScript and find a way to formalize it through the type system. We added static types for a dynamically typed language, to communicate intent, get tooling, and catch bugs before they happen.

Some parts in JavaScript don’t really care about static types, though. For example, an isKeyAvailableInObject function should only check if a key is available in an object; it doesn’t need to know about the concrete types. To properly formalize a function like this we can use TypeScript’s structural type system and describe either a very wide type for the price of information or a very strict type for the price of flexibility.

But we don’t want to pay any price. We want both flexibility and information. Generics in TypeScript are just the silver bullet we need. We can describe complex relationships and formalize structure for data that has not been defined yet.

Generics, along with its gang of mapped types, type maps, type modifiers, and helper types, open the door to metatyping, where we can create new types based on old ones and keep relationships between types intact while the newly generated types challenge our original code for possible bugs.

This is the entrance to advanced TypeScript concepts. But fear not, there shan’t be dragons, unless we define them.

4.1 Generalizing Function Signatures

Problem

You have two functions that work the same, but on different and largely incompatible types.

Solution

Generalize their behavior using generics.

Discussion

You are writing an application that stores several language files (for example, subtitles) in an object. The keys are the language codes, and the values are URLs. You load language files by selecting them via a language code, which comes from some API or user interface as string. To make sure the language code is correct and valid, you add an isLanguageAvailable function that does an in check and sets the correct type using a type predicate:

type Languages = {
  de: URL;
  en: URL;
  pt: URL;
  es: URL;
  fr: URL;
  ja: URL;
};

function isLanguageAvailable(
  collection: Languages,
  lang: string
): lang is keyof Languages {
  return lang in collection;
}

function loadLanguage(collection: Languages, lang: string) {
  if (isLanguageAvailable(collection, lang)) {
    // lang is keyof Languages
    collection[lang]; // access ok!
  }
}

Same application, different scenario, entirely different file. You load media data into an HTML element: either audio, video, or a combination with certain animations in a canvas element. All elements exist in the application already, but you need to select the right one based on input from an API. Again, the selection comes as string, and you write an isElementAllowed function to ensure that the input is actually a valid key of your AllowedElements collection:

type AllowedElements = {
  video: HTMLVideoElement;
  audio: HTMLAudioElement;
  canvas: HTMLCanvasElement;
};

function isElementAllowed(
  collection: AllowedElements,
  elem: string
): elem is keyof AllowedElements {
  return elem in collection;
}

function selectElement(collection: AllowedElements, elem: string) {
  if (isElementAllowed(collection, elem)) {
    // elem is keyof AllowedElements
    collection[elem]; // access ok
  }
}

You don’t need to look too closely to see that both scenarios are very similar. The type guard functions especially catch our eye. If we strip away all the type information and align the names, they are identical:

function isAvailable(obj, key) {
  return key in obj;
}

The two of them exist because of the type information we get. Not because of the input parameters, but because of the type predicates. In both scenarios we can tell more about the input parameters by asserting a specific keyof type.

The problem is that both input types for the collection are entirely different and have no overlap. Except for the empty object, for which we don’t get that much valuable information if we create a keyof type. keyof {} is actually never.

But there is some type information here that we can generalize. We know the first input parameter is an object. And the second one is a property key. If this check evaluates to true, we know that the first parameter is a key of the second parameter.

To generalize this function, we can add a generic type parameter to isAvailable called Obj, put in angle brackets. This is a placeholder for an actual type that will be substituted once isAvailable is used. We can use this generic type parameter like we would use AllowedElements or Languages and can add a type predicate. Since Obj can be substituted for every type, key needs to include all possible property keys—string, symbol, and number:

function isAvailable<Obj>(
  obj: Obj,
  key: string | number | symbol
): key is keyof Obj {
  return key in obj;
}

function loadLanguage(collection: Languages, lang: string) {
  if (isAvailable(collection, lang)) {
    // lang is keyof Languages
    collection[lang]; // access ok!
  }
}

function selectElement(collection: AllowedElements, elem: string) {
  if (isAvailable(collection, elem)) {
    // elem is keyof AllowedElements
    collection[elem]; // access ok
  }
}

And there you have it: one function that works in both scenarios, no matter which types we substitute Obj for. Just like JavaScript works! We still get the same functionality, and we get the right type information. Index access becomes safe, without sacrificing flexibility.

The best part? We can use isAvailable just like we would use an untyped JavaScript equivalent. This is because TypeScript infers types for generic type parameters through usage. And this comes with some neat side effects. You can read more about that in Recipe 4.3.

4.3 Getting Rid of any and unknown

Problem

Generic type parameters, any, and unknown all seem to describe very wide sets of values. When should you use what?

Solution

Use generic type parameters when you get to the actual type eventually; refer to Recipe 2.2 on the decision between any and unknown.

Discussion

When we are using generics, they might seem like a substitute for any and unknown. Take an identity function—its only job is to return the value passed as input parameter:

function identity(value: any): any {
  return value;
}

let a = identity("Hello!");
let b = identity(false);
let c = identity(2);

It takes values of every type, and the return type of it can also be anything. We can write the same function using unknown if we want to safely access properties:

function identity(value: unknown): unknown {
  return value;
}

let a = identity("Hello!");
let b = identity(false);
let c = identity(2);

We can even mix and match any and unknown, but the result is always the same: Type information is lost. The type of the return value is what we define it to be.

Now let’s write the same function with generics instead of any or unknown. Its type annotations say that the generic type is also the return type:

function identity<T>(t: T): T {
  return t;
}

We can use this function to pass in any value and see which type TypeScript infers:

let a = identity("Hello!"); // a is string
let b = identity(2000);     // b is number
let c = identity({ a: 2 }); // c is { a: number }

Assigning to a binding with const instead of let gives slightly different results:

const a = identity("Hello!"); // a is "Hello!"
const b = identity(2000);     // b is 2000
const c = identity({ a: 2 }); // c is { a: number }

For primitive types, TypeScript substitutes the generic type parameter with the actual type. We can make great use of this in more advanced scenarios.

With TypeScript’s generics, it’s also possible to annotate the generic type parameter:

const a = identity<string>("Hello!"); // a is string
const b = identity<number>(2000);     // b is number
const c = identity<{ a: 2 }>({ a: 2 }); // c is { a: 2 }

If this behavior reminds you of annotation and inference described in Recipe 3.4, you are absolutely right. It’s very similar but with generic type parameters in functions.

When using generics without constraints, we can write functions that work with values of any type. Inside, they behave like unknown, which means we can do type guards to narrow the type. The biggest difference is that once we use the function, we substitute our generics with real types, not losing any information on typing at all.

This allows us to be a bit clearer with our types than just allowing everything. This pairs function takes two arguments and creates a tuple:

function pairs(a: unknown, b: unknown): [unknown, unknown] {
  return [a, b];
}

const a = pairs(1, "1"); // [unknown, unknown]

With generic type parameters, we get a nice tuple type:

function pairs<T, U>(a: T, b: U): [T, U] {
  return [a, b];
}

const b = pairs(1, "1"); // [number, string]

Using the same generic type parameter, we can make sure we get tuples only where each element is of the same type:

function pairs<T>(a: T, b: T): [T, T] {
  return [a, b];
}

const c = pairs(1, "1");
//                  ^
// Argument of type 'string' is not assignable to parameter of type 'number'

So, should you use generics everywhere? Not necessarily. This chapter includes many solutions that rely on getting the right type information at the right time. When you are happy with a wider set of values and can rely on subtypes being compatible, you don’t need to use generics at all. If you have any and unknown in your code, think whether you need the actual type at some point. Adding a generic type parameter instead might help.

4.4 Understanding Generic Instantiation

Problem

You understand how generics are substituted for real types, but sometimes errors like “Foo is assignable to the constraint of type Bar, but could be instantiated with a different subtype of constraint Baz” confuse you.

Solution

Remember that values of a generic type can be—explicitly and implicitly—substituted with a variety of subtypes. Write subtype-friendly code.

Discussion

You create a filter logic for your application. You have different filter rules that you can combine using "and" | "or" combinators. You can also chain regular filter rules with the outcome of combinatorial filters. You create your types based on this behavior:

type FilterRule = {
  field: string;
  operator: string;
  value: any;
};

type CombinatorialFilter = {
  combinator: "and" | "or";
  rules: FilterRule[];
};

type ChainedFilter = {
  rules: (CombinatorialFilter | FilterRule)[];
};

type Filter = CombinatorialFilter | ChainedFilter;

Now you want to write a reset function that, based on an already provided filter, resets all rules. You use type guards to distinguish between CombinatorialFilter and ChainedFilter:

function reset(filter: Filter): Filter {
  if ("combinator" in filter) {
    // filter is CombinatorialFilter
    return { combinator: "and", rules: [] };
  }
  // filter is ChainedFilter
  return { rules: [] };
}

const filter: CombinatorialFilter = { rules: [], combinator: "or" };
const resetFilter = reset(filter); // resetFilter is Filter

The behavior is what you are after, but the return type of reset is too wide. When we pass a CombinatorialFilter, we should be sure that the reset filter is also a Co⁠mb⁠in⁠ato⁠rial​Fil⁠ter. Here it’s the union type, just like our function signature indicates. But you want to make sure that if you pass a filter of a certain type, you also get the same return type. So you replace the broad union type with a generic type parameter that is constrained to Filter. The return type works as intended, but the implementation of your function throws errors:

function reset<F extends Filter>(filter: F): F {
  if ("combinator" in filter) {
    return { combinator: "and", rules: [] };
//  ^ '{ combinator: "and"; rules: never[]; }' is assignable to
//     the constraint of type 'F', but 'F' could be instantiated
//     with a different subtype of constraint 'Filter'.
  }
  return { rules: [] };
//^ '{ rules: never[]; }' is assignable to the constraint of type 'F',
//   but 'F' could be instantiated with a different subtype of
//   constraint 'Filter'.
}

const resetFilter = reset(filter); // resetFilter is CombinatorialFilter

While you want to differentiate between two parts of a union, TypeScript thinks more broadly. It knows that you might pass in an object that is structurally compatible with Filter, but it has more properties and is therefore a subtype.

This means you can call reset with F instantiated to a subtype, and your program would happily override all excess properties. This is wrong, and TypeScript tells you that:

const onDemandFilter = reset({
  combinator: "and",
  rules: [],
  evaluated: true,
  result: false,
});
/* filter is {
    combinator: "and";
    rules: never[];
    evaluated: boolean;
    result: boolean;
}; */

Overcome this by writing subtype-friendly code. Clone the input object (still type F), set the properties that need to be changed accordingly, and return something that is still of type F:

function reset<F extends Filter>(filter: F): F {
  const result = { ...filter }; // result is F
  result.rules = [];
  if ("combinator" in result) {
    result.combinator = "and";
  }
  return result;
}

const resetFilter = reset(filter); // resetFilter is CombinatorialFilter

Generic types can be one of many in a union, but they can be much, much more. TypeScript’s structural type system allows you to work on a variety of subtypes, and your code needs to reflect that.

Here’s a different scenario but with a similar outcome. You want to create a tree data structure and write a recursive type that stores all tree items. This type can be subtyped, so you write a createRootItem function with a generic type parameter since you want to instantiate it with the correct subtype:

type TreeItem = {
  id: string;
  children: TreeItem[];
  collapsed?: boolean;
};

function createRootItem<T extends TreeItem>(): T {
  return {
    id: "root",
    children: [],
  };
// '{ id: string; children: never[]; }' is assignable to the constraint
//   of type 'T', but 'T' could be instantiated with a different subtype
//   of constraint 'TreeItem'.(2322)
}

const root = createRootItem(); // root is TreeItem

We get a similar error as before, since we can’t possibly say that the return value will be compatible with all the subtypes. To solve this problem, get rid of the generic! We know how the return type will look—it’s a TreeItem:

function createRootItem(): TreeItem {
  return {
    id: "root",
    children: [],
  };
}

The simplest solutions are often the better ones. But now you want to extend your software by being able to attach children of type or subtype TreeItem to a newly created root. We don’t add any generics yet and are somewhat dissatisfied:

function attachToRoot(children: TreeItem[]): TreeItem {
  return {
    id: "root",
    children,
  };
}

const root = attachToRoot([]); // TreeItem

root is of type TreeItem, but we lose any information about the subtyped children. Even if we add a generic type parameter just for the children, constrained to Tr⁠ee​It⁠em, we don’t retain this information on the go:

function attachToRoot<T extends TreeItem>(children: T[]): TreeItem {
  return {
    id: "root",
    children,
  };
}

const root = attachToRoot([
  {
    id: "child",
    children: [],
    collapsed: false,
    marked: true,
  },
]); // root is TreeItem

When we start adding a generic type as a return type, we run into the same problems as before. To solve this issue, we need to split the root item type from the children item type, by opening up TreeItem to be a generic, where we can set Children to be a subtype of TreeItem.

Since we want to avoid any circular references, we need to set Children to a default BaseTreeItem, so we can use TreeItem both as a constraint for Children and for attachToRoot:

type BaseTreeItem = {
  id: string;
  children: BaseTreeItem[];
};

type TreeItem<Children extends TreeItem = BaseTreeItem> = {
  id: string;
  children: Children[];
  collapsed?: boolean;
};

function attachToRoot<T extends TreeItem>(children: T[]): TreeItem<T> {
  return {
    id: "root",
    children,
  };
}

const root = attachToRoot([
  {
    id: "child",
    children: [],
    collapsed: false,
    marked: true,
  },
]);
/*
root is TreeItem<{
    id: string;
    children: never[];
    collapsed: false;
    marked: boolean;
}>
*/

Again, we write subtype friendly and treat our input parameters as their own, instead of making assumptions.

4.5 Generating New Object Types

Problem

You have a type in your application that is related to your model. Every time the model changes, you need to change your types as well.

Solution

Use generic mapped types to create new object types based on the original type.

Discussion

Let’s go back to the toy shop from Recipe 3.1. Thanks to union types, intersection types, and discriminated union types, we were able to model our data quite nicely:

type ToyBase = {
  name: string;
  description: string;
  minimumAge: number;
};

type BoardGame = ToyBase & {
  kind: "boardgame";
  players: number;
};

type Puzzle = ToyBase & {
  kind: "puzzle";
  pieces: number;
};

type Doll = ToyBase & {
  kind: "doll";
  material: "plush" | "plastic";
};

type Toy = Doll | Puzzle | BoardGame;

Somewhere in our code, we need to group all toys from our model in a data structure that can be described by a type called GroupedToys. GroupedToys has a property for each category (or "kind") and a Toy array as value. A groupToys function takes an unsorted list of toys and groups them by kind:

type GroupedToys = {
  boardgame: Toy[];
  puzzle: Toy[];
  doll: Toy[];
};

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {
    boardgame: [],
    puzzle: [],
    doll: [],
  };
  for (let toy of toys) {
    groups[toy.kind].push(toy);
  }
  return groups;
}

There are already some niceties in this code. First, we use an explicit type annotation when declaring groups. This ensures we are not forgetting any category. Also, since the keys of GroupedToys are the same as the union of "kind" types in Toy, we can easily index access groups by toy.kind.

Months and sprints pass, and we need to touch our model again. The toy shop is now selling original or maybe alternate vendors of interlocking toy bricks. We wire the new type Bricks up to our Toy model:

type Bricks = ToyBase & {
  kind: "bricks",
  pieces: number;
  brand: string;
}

type Toy = Doll | Puzzle | BoardGame | Bricks;

Since groupToys needs to deal with Bricks, too, we get a nice error because GroupedToys has no clue about a "bricks" kind:

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {
    boardgame: [],
    puzzle: [],
    doll: [],
  };
  for (let toy of toys) {
    groups[toy.kind].push(toy);
//  ^- Element implicitly has an 'any' type because expression
//     of type '"boardgame" | "puzzle" | "doll" | "bricks"' can't
//     be used to index type 'GroupedToys'.
//     Property 'bricks' does not exist on type 'GroupedToys'.(7053)
  }
  return groups;
}

This is desired behavior in TypeScript: knowing when types don’t match anymore. This should draw our attention. Let’s give GroupedToys and groupToys an update:

type GroupedToys = {
  boardgame: Toy[];
  puzzle: Toy[];
  doll: Toy[];
  bricks: Toy[];
};

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {
    boardgame: [],
    puzzle: [],
    doll: [],
    bricks: [],
  };
  for (let toy of toys) {
    groups[toy.kind].push(toy);
  }
  return groups;
}

There is one bothersome thing: the task of grouping toys is always the same. No matter how much our model changes, we will always select by kind and push into an array. We would need to maintain groups with every change, but if we change how we think about groups, we can optimize for change. First, we change the type Gr⁠oup⁠ed​To⁠ys to feature optional properties. Second, we initialize each group with an empty array if there hasn’t been any initialization yet:

type GroupedToys = {
  boardgame?: Toy[];
  puzzle?: Toy[];
  doll?: Toy[];
  bricks?: Toy[];
};


function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {};
  for (let toy of toys) {
    // Initialize when not available
    groups[toy.kind] = groups[toy.kind] ?? [];
    groups[toy.kind]?.push(toy);
  }
  return groups;
}

We don’t need to maintain groupToys anymore. The only thing that needs maintenance is the type GroupedToys. If we look closely at GroupedToys, we see that there is an implicit relation to Toy. Each property key is part of Toy["kind"]. Let’s make this relation explicit. With a mapped type, we create a new object type based on each type in Toy["kind"].

Toy["kind"] is a union of string literals: "boardgame" | "puzzle" | "doll" | "bricks". Since we have a very reduced set of strings, each element of this union will be used as its own property key. Let that sink in for a moment: we can use a type to be a property key of a newly generated type. Each property has an optional type modifier and points to a Toy[]:

type GroupedToys = {
  [k in Toy["kind"]]?: Toy[];
};

Fantastic! Every time we change Toy, we immediately change Toy[]. Our code needs no change at all; we can still group by kind as we did before.

This is a pattern we have the potential to generalize. Let’s create a Group type that takes a collection and groups it by a specific selector. We want to create a generic type with two type parameters:

  • The Collection can be anything.

  • The Selector, a key of Collection, so it can create the respective properties.

Our first attempt would be to take what we had in GroupedToys and replace the concrete types with type parameters. This creates what we need but also causes an error:

// How to use it
type GroupedToys = Group<Toy, "kind">;

type Group<Collection, Selector extends keyof Collection> = {
  [x in Collection[Selector]]?: Collection[];
//     ^ Type 'Collection[Selector]' is not assignable
//       to type 'string | number | symbol'.
//       Type 'Collection[keyof Collection]' is not
//       assignable to type 'string | number | symbol'.
//       Type 'Collection[string] | Collection[number]
//        | Collection[symbol]' is not assignable to
//       type 'string | number | symbol'.
//       Type 'Collection[string]' is not assignable to
//       type 'string | number | symbol'.(2322)
};

TypeScript warns us that Collection[string] | Collection[number] | Collection[symbol] could result in anything, not just things that can be used as a key. That’s true, and we need to prepare for that. We have two options.

First, use a type constraint on Collection that points to Record<string, any>. Record is a utility type that generates a new object where the first parameter gives you all keys and the second parameter gives you the types:

// This type is built-in!
type Record<K extends string | number | symbol, T> = { [P in K]: T; };

This elevates Collection to a wildcard object, effectively disabling the type-check from Groups. This is OK because if something would be an unusable type for a property key, TypeScript will throw it away anyway. So the final Group has two constrained type parameters:

type Group<
  Collection extends Record<string, any>,
  Selector extends keyof Collection
> = {
  [x in Collection[Selector]]: Collection[];
};

The second option is to do a check for each key to see if it is a valid string key. We can use a conditional type to see if Collection[Selector] is in fact a valid type for a key. Otherwise, we would remove this type by choosing never. Conditional types are their own beast, and we tackle this in Recipe 5.4 extensively:

type Group<Collection, Selector extends keyof Collection> = {
  [k in Collection[Selector] extends string
    ? Collection[Selector]
    : never]?: Collection[];
};

Note that we did remove the optional type modifier. We do this because making keys optional is not the task of grouping. We have another type for that: Partial<T>, another mapped type that makes every property in an object type optional:

// This type is built-in!
type Partial<T> = { [P in keyof T]?: T[P] };

No matter which Group helper you create, you can now create a GroupedToys object by telling TypeScript that you want a Partial (changing everything to optional properties) of a Group of Toys by "kind":

type GroupedToys = Partial<Group<Toy, "kind">>;

Now that reads nicely.

4.6 Modifying Objects with Assertion Signatures

Problem

After a certain function execution in your code, you know the type of a value has changed.

Solution

Use assertion signatures to change types independently of if and switch statements.

Discussion

JavaScript is a very flexible language. Its dynamic typing features allow you to change objects at runtime, adding new properties on the fly. And developers use this. There are situations where you, for example, run over a collection of elements and need to assert certain properties. You then store a checked property and set it to true, just so you know that you passed a certain mark:

function check(person: any) {
  person.checked = true;
}

const person = {
  name: "Stefan",
  age: 27,
};

check(person); // person now has the checked property

person.checked; // this is true!

You want to mirror this behavior in the type system; otherwise, you would need to constantly do extra checks if certain properties are in an object, even though you can be sure that they exist.

One way to assert that certain properties exist are, well, type assertions. We say that at a certain point in time, this property has a different type:

(person as typeof person & { checked: boolean }).checked = true;

Good, but you would need to do this type assertion over and over again, as they don’t change the original type of person. Another way to assert that certain properties are available is to create type predicates, like those shown in Recipe 3.5:

function check<T>(obj: T): obj is T & { checked: true } {
  (obj as T & { checked: boolean }).checked = true;
  return true;
}

const person = {
  name: "Stefan",
  age: 27,
};

if (check(person)) {
  person.checked; // checked is true!
}

This situation is a bit different, though, which makes the check function feel clumsy: you need to do an extra condition and return true in the predicate function. This doesn’t feel right.

Thankfully, TypeScript has another technique we can leverage in situations like this: assertion signatures. Assertion signatures can change the type of a value in control flow, without the need for conditionals. They have been modeled for the Node.js assert function, which takes a condition, and it throws an error if it isn’t true. This means that, after calling assert, you might have more information than before. For example, if you call assert and check if a value has a type of string, you know that after this assert function the value should be string:

function assert(condition: any, msg?: string): asserts condition {
  if (!condition) {
    throw new Error(msg);
  }
}

function yell(str: any) {
  assert(typeof str === "string");
  // str is string
  return str.toUpperCase();
}

Please note that the function short-circuits if the condition is false. It throws an error, the never case. If this function passes, you can really assert the condition.

While assertion signatures have been modeled for the Node.js assert function, you can assert any type you like. For example, you can have a function that takes any value for an addition, but you assert that the values need to be number to continue:

function assertNumber(val: any): asserts val is number {
  if (typeof val !== "number") {
    throw Error("value is not a number");
  }
}

function add(x: unknown, y: unknown): number {
  assertNumber(x); // x is number
  assertNumber(y); // y is number
  return x + y;
}

All the examples you find on assertion signatures are based after assertions and short-circuit with errors. But we can take the same technique to tell TypeScript that more properties are available. We write a function that is very similar to check in the predicate function before, but this time we don’t need to return true. We set the property, and since objects are passed by value in JavaScript, we can assert that after calling this function whatever we pass has a property checked, which is true:

function check<T>(obj: T): asserts obj is T & { checked: true } {
  (obj as T & { checked: boolean }).checked = true;
}

const person = {
  name: "Stefan",
  age: 27,
};

check(person);

And with that, we can modify a value’s type on the fly. It’s a little-known technique that can help you a lot.

4.7 Mapping Types with Type Maps

Problem

You write a factory function that creates an object of a specific subtype based on a string identifier, and there are a lot of possible subtypes.

Solution

Store all subtypes in a type map, widen with index access, and use mapped types like Partial<T>.

Discussion

Factory functions are great if you want to create variants of complex objects based on some basic information. One scenario that you might know from browser JavaScript is the creation of elements. The document.createElement function accepts an element’s tag name, and you get an object where you can modify all necessary properties.

You want to spice up this creation with a neat factory function you call cr⁠ea⁠te​El⁠eme⁠nt. Not only does it take the element’s tag name, but it also makes a list of properties so you don’t need to set each property individually:

// Using create Element

// a is HTMLAnchorElement
const a = createElement("a", { href: "https://fettblog.eu" });
// b is HTMLVideoElement
const b = createElement("video", { src: "/movie.mp4", autoplay: true });
// c is HTMLElement
const c = createElement("my-element");

You want to create good types for this, so you need to take care of two things:

  • Make sure you create only valid HTML elements.

  • Provide a type that accepts a subset of an HTML element’s properties.

Let’s take care of the valid HTML elements first. There are around 140 possible HTML elements, which is a lot. Each of those elements has a tag name, which can be represented as a string, and a respective prototype object in the DOM. Using the dom lib in your tsconfig.json, TypeScript has information on those prototype objects in the form of types. And you can figure out all 140 element names.

A good way to provide a mapping between element tag names and prototype objects is to use a type map. A type map is a technique where you take a type alias or interface and let keys point to the respective type variants. You can then get the correct type variant using index access of a string literal type:

type AllElements = {
  a: HTMLAnchorElement;
  div: HTMLDivElement;
  video: HTMLVideoElement;
  //... and ~140 more!
};

// HTMLAnchorElement
type A = AllElements["a"];

It looks like accessing a JavaScript object’s properties using index access, but remember that we’re still working on a type level. This means index access can be broad:

type AllElements = {
  a: HTMLAnchorElement;
  div: HTMLDivElement;
  video: HTMLVideoElement;
  //... and ~140 more!
};

// HTMLAnchorElement | HTMLDivELement
type AandDiv = AllElements["a" | "div"];

Let’s use this map to type the createElement function. We use a generic type parameter constrained to all keys of AllElements, which allows us to pass only valid HTML elements:

function createElement<T extends keyof AllElements>(tag: T): AllElements[T] {
  return document.createElement(tag as string) as AllElements[T];
}

// a is HTMLAnchorElement
const a = createElement("a");

Use generics here to pin a string literal to a literal type, which we can use to index the right HTML element variant from the type map. Also note that using do⁠cum⁠ent.​cre⁠ate⁠Ele⁠me⁠nt requires two type assertions. One makes the set wider (T to string), and one makes the set narrower (HTMLElement to AllElements[T]). Both assertions indicate that we have to deal with an API outside our control, as established in Recipe 3.9. We will deal with the assertions later on.

Now we want to provide the option to pass extra properties for said HTML elements, to set an href to an HTMLAnchorElement, and so forth. All properties are already in the respective HTMLElement variants, but they’re required, not optional. We can make all properties optional with the built-in type Partial<T>. It’s a mapped type that takes all properties of a certain type and adds a type modifier:

type Partial<T> = { [P in keyof T]?: T[P] };

We extend our function with an optional argument props that is a Partial of the indexed element from AllElements. This way, we know that if we pass an "a", we can only set properties that are available in HTMLAnchorElement:

function createElement<T extends keyof AllElements>(
  tag: T,
  props?: Partial<AllElements[T]>
): AllElements[T] {
  const elem = document.createElement(tag as string) as AllElements[T];
  return Object.assign(elem, props);
}

const a = createElement("a", { href: "https://fettblog.eu" });
const x = createElement("a", { src: "https://fettblog.eu" });
//                           ^--
// Argument of type '{ src: string; }' is not assignable to parameter
// of type 'Partial<HTMLAnchorElement>'.
// Object literal may only specify known properties, and 'src' does not
// exist in type 'Partial<HTMLAnchorElement>'.(2345)

Fantastic! Now it’s up to you to figure out all 140 HTML elements. Or not. Somebody already did the work and put HTMLElementTagNameMap into lib.dom.ts. So let’s use this instead:

function createElement<T extends keyof HTMLElementTagNameMap>(
  tag: T,
  props?: Partial<HTMLElementTagNameMap[T]>
): HTMLElementTagNameMap[T] {
  const elem = document.createElement(tag);
  return Object.assign(elem, props);
}

This is also the interface used by document.createElement, so there is no friction between your factory function and the built-in one. No extra assertions necessary.

There is only one caveat. You are restricted to the 140 elements provided by HT⁠ML​Ele⁠men⁠tTa⁠gNa⁠me⁠Map. What if you want to create SVG elements, or web components that can have fully customized element names? Your factory function suddenly is too constrained.

To allow for more—as document.createElement does—we would need to add all possible strings to the mix again. HTMLElementTagNameMap is an interface. So we can use declaration merging to extend the interface with an indexed signature, where we map all remaining strings to HTMLUnknownElement:

interface HTMLElementTagNameMap {
  [x: string]: HTMLUnknownElement;
};

function createElement<T extends keyof HTMLElementTagNameMap>(
  tag: T,
  props?: Partial<HTMLElementTagNameMap[T]>
): HTMLElementTagNameMap[T] {
  const elem = document.createElement(tag);
  return Object.assign(elem, props);
}

// a is HTMLAnchorElement
const a = createElement("a", { href: "https://fettblog.eu" });
// b is HTMLUnknownElement
const b = createElement("my-element");

Now we have everything we want:

  • A great factory function to create typed HTML elements

  • The possibility to set element properties with just one configuration object

  • The flexibility to create more elements than defined

The last is great, but what if you only want to allow for web components? Web components have a convention; they need to have a dash in their tag name. We can model this using a mapped type on a string template literal type. You will learn all about string template literal types in Chapter 6.

For now, the only thing you need to know is that we create a set of strings where the pattern is any string followed by a dash followed by any string. This is enough to ensure we only pass correct element names.

Mapped types work only with type aliases, not interface declarations, so we need to define an AllElements type again:

type AllElements = HTMLElementTagNameMap &
  {
    [x in `${string}-${string}`]: HTMLElement;
  };

function createElement<T extends keyof AllElements>(
  tag: T,
  props?: Partial<AllElements[T]>
): AllElements[T] {
  const elem = document.createElement(tag as string) as AllElements[T];
  return Object.assign(elem, props);
}

const a = createElement("a", { href: "https://fettblog.eu" }); // OK
const b = createElement("my-element"); // OK


const c = createElement("thisWillError");
//                      ^
// Argument of type '"thisWillError"' is not
// assignable to parameter of type '`${string}-${string}`
// | keyof HTMLElementTagNameMap'.(2345)

Fantastic. With the AllElements type we also get type assertions back, which we don’t like that much. In that case, instead of asserting, we can also use a function overload, defining two declarations: one for our users, and one for us to implement the function. You can learn more about this function overload technique in Recipes 2.6 and 12.7:

function createElement<T extends keyof AllElements>(
  tag: T,
  props?: Partial<AllElements[T]>
): AllElements[T];
function createElement(tag: string, props?: Partial<HTMLElement>): HTMLElement {
  const elem = document.createElement(tag);
  return Object.assign(elem, props);
}

We are all set. We defined a type map with mapped types and index signatures, using generic type parameters to be very explicit about our intentions. A great combination of multiple tools in our TypeScript tool belt.

4.8 Using ThisType to Define this in Objects

Problem

Your app requires complex configuration objects with methods, where this has a different context depending on usage.

Solution

Use the built-in generic ThisType<T> to define the correct this.

Discussion

Frameworks like VueJS rely a lot on factory functions, where you pass a comprehensive configuration object to define initial data, computed properties, and methods for each instance. You want to create a similar behavior for components of your app. The idea is to provide a configuration object with three properties:

A data function

The return value is the initial data for the instance. You should not have access to any other properties from the configuration object in this function.

A computed property

This is for computed properties, which are based on the initial data. Computed properties are declared using functions. They can access initial data just like normal properties.

A methods property

Methods can be called and can access computed properties as well as the initial data. When methods access computed properties, they access it like they would access normal properties: no need to call the function.

Looking at the configuration object in use, there are three different ways to interpret this. In data, this doesn’t have any properties at all. In computed, each function can access the return value of data via this just like it would be part of their object. In methods, each method can access computed properties and data via this in the same way:

const instance = create({
  data() {
    return {
      firstName: "Stefan",
      lastName: "Baumgartner",
    };
  },
  computed: {
    fullName() {
      // has access to the return object of data
      return this.firstName + " " + this.lastName;
    },
  },
  methods: {
    hi() {
      // use computed properties just like normal properties
      alert(this.fullName.toLowerCase());
    },
  },
});

This behavior is special but not uncommon. And with a behavior like that, we definitely want to rely on good types.

Note

In this lesson we will focus only on the types, not on the actual implementation, as that would exceed this chapter’s scope.

Let’s create types for each property. We define a type Options, which we are going to refine step by step. First is the data function. data can be user defined, so we want to specify data using a generic type parameter. The data we are looking for is specified by the return type of the data function:

type Options<Data> = {
  data(this: {})?: Data;
};

So once we specify an actual return value in the data function, the Data placeholder gets substituted with the real object’s type. Note that we also define this to point to the empty object, which means that we don’t get access to any other property from the configuration object.

Next, we define computed. computed is an object of functions. We add another generic type parameter called Computed and let the value of Computed be typed through usage. Here, this changes to all the properties of Data. Since we can’t set this like we do in the data function, we can use the built-in helper type ThisType and set it to the generic type parameter Data:

type Options<Data, Computed> = {
  data(this: {})?: Data;
  computed?: Computed & ThisType<Data>;
};

This allows us to access, for example, this.firstName, like in the previous example. Last but not least, we want to specify methods. methods is again special, as you are getting access not only to Data via this but also to all methods and to all computed properties as properties.

Computed holds all computed properties as functions. We would need their value, though—more specifically, their return value. If we access fullName via property access, we expect it to be a string.

For that, we create a helper type called MapFnToProp. It takes a type that is an object of functions and maps it to the return values’ types. The built-in ReturnType helper type is perfect for this scenario:

// An object of functions ...
type FnObj = Record<string, () => any>;

// ... to an object of return types
type MapFnToProp<FunctionObj extends FnObj> = {
  [K in keyof FunctionObj]: ReturnType<FunctionObj[K]>;
};

We can use MapFnToProp to set ThisType for a newly added generic type parameter called Methods. We also add Data and Methods to the mix. To pass the Computed generic type parameter to MapFnToProp, it needs to be constrained to FnObj, the same constraint of the first parameter FunctionObj in MapFnToProp:

type Options<Data, Computed extends FnObj, Methods> = {
  data(this: {})?: Data;
  computed?: Computed & ThisType<Data>;
  methods?: Methods & ThisType<Data & MapFnToProp<Computed> & Methods>;
};

And that’s the type! We take all generic type properties and add them to the create factory function:

declare function create<Data, Computed extends FnObj, Methods>(
  options: Options<Data, Computed, Methods>
): any;

Through usage, all generic type parameters will be substituted. And the way Options is typed, we get all the autocomplete necessary to ensure we don’t run into troubles, as seen in Figure 4-1.

This example shows wonderfully how TypeScript can be used to type elaborate APIs where a lot of object manipulation is happening underneath.1

tscb 0401
Figure 4-1. The methods configuration in the factory function having all the access to the correct properties

4.9 Adding Const Context to Generic Type Parameters

Problem

When you pass complex, literal values to a function, TypeScript widens the type to something more general. While this is desired behavior in a lot of cases, in some you want to work on the literal types rather than the widened type.

Solution

Add a const modifier in front of your generic type parameter to keep the passed values in const context.

Discussion

Single-page application (SPA) frameworks tend to reimplement a lot of browser functionality in JavaScript. For example, features like the History API made it possible to override the regular navigation behavior, which SPA frameworks use to switch between pages without a real page reload, by swapping the content of the page and changing the URL in the browser.

Imagine working on a minimalistic SPA framework that uses a so-called router to navigate between pages. Pages are defined as components, and a ComponentConstructor interface knows how to instantiate and render new elements on your website:

interface ComponentConstructor {
  new(): Component;
}

interface Component {
  render(): HTMLElement;
}

The router should take a list of components and associated paths, stored as string. When creating a router through the router function, it should return an object that lets you navigate the desired path:

type Route = {
  path: string;
  component: ComponentConstructor;
};

function router(routes: Route[]) {
  return {
    navigate(path: string) {
      // ...
    },
  };
}

How the actual navigation is implemented is of no concern to us right now; instead, we want to focus on the typings of the function interface.

The router works as intended; it takes an array of Route objects and returns an object with a navigate function, which allows us to trigger the navigation from one URL to the other and renders the new component:

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
])

rtr.navigate("/faq");

What you immediately see is that the types are way too broad. If we allow navigating to every string available, nothing keeps us from using bogus routes that lead nowhere. We would need to implement some sort of error handling for information that is already ready and available. So why not use it?

Our first idea would be to replace the concrete type with a generic type parameter. The way TypeScript deals with generic substitution is that if we have a literal type, TypeScript will subtype accordingly. Introducing T for Route and using T["path"] instead of string comes close to what we want to achieve:

function router<T extends Route>(routes: T[]) {
  return {
    navigate(path: T["path"]) {
      // ...
    },
  };
}

In theory, this should work. If we remind ourselves what TypeScript does with literal, primitives types in that case, we would expect the value to be narrowed to the literal type:

function getPath<T extends string>(route: T): T {
  return route;
}

const path = getPath("/"); // "/"

You can read more on that in Recipe 4.3. One important detail is that path in the previous example is in a const context, because the returned value is immutable.

The only problem is that we are working with objects and arrays, and TypeScript tends to widen types in objects and arrays to something more general to allow for the mutability of values. If we look at a similar example, but with a nested object, we see that TypeScript takes the broader type instead:

type Routes = {
  paths: string[];
};

function getPaths<T extends Routes>(routes: T): T["paths"] {
  return routes.paths;
}

const paths = getPaths({ paths: ["/", "/about"] }); // string[]

For objects, the const context for paths is only for the binding of the variable, not for its contents. This eventually leads to losing some of the information we need to correctly type navigate.

A way to work around this limitation is to manually apply const context, which needs us to redefine the input parameter to be readonly:

function router<T extends Route>(routes: readonly T[]) {
  return {
    navigate(path: T["path"]) {
      history.pushState({}, "", path);
    },
  };
}

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
] as const);

rtr.navigate("/about");

This works but also requires that we not forget a very important detail when coding. And actively remembering workarounds is always a recipe for disaster.

Thankfully, TypeScript allows us to request const context from generic type parameters. Instead of applying it to the value, we substitute the generic type parameter for a concrete value but in const context by adding the const modifier to the generic type parameter:

function router<const T extends Route>(routes: T[]) {
  return {
    navigate(path: T["path"]) {
      // tbd
    },
  };
}

We can then use our router just as we are accustomed to and even get autocomplete for possible paths:

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
])

rtr.navigate("/about");

Even better, we get proper errors when we pass in something bogus:

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
])

rtr.navigate("/faq");
//             ^
// Argument of type '"/faq"' is not assignable to
// parameter of type '"/" | "/about"'.(2345)

The beautiful thing: it’s all hidden in the function’s API. What we expect becomes clearer, the interface tells us the constraints, and we don’t have to do anything extra when using router to ensure type safety.

1 Special thanks to the creators of Type Challenges for this beautiful example.

Get TypeScript Cookbook now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.