Posted by Sean Newell on April 06, 2023 | typescript

TypeScript has taken the front end world by storm and is eating its way into the backend and fullstack world as well. It’s important to get familiar with the tool and how best to leverage it, as it has some super powers beyond simple string | number types or interfaces like type checking the format of a string or doing exhaustive switch case checking that some may not be as familiar with.

TypeScript in the wild

If you’ve been a JavaScript or web developer in the last decade, you’ve probably noticed TypeScript eat up more and more mindshare. It’s not going away anytime soon and has completely overshadowed the likes of Flow, CoffeeScript, and others.

state of js 2022 javascript flavors

Percentage wise, TypeScript took home 68.4% with 69.2% of respondents choosing to answer this question on the survey.

But what is the quality of TypeScript out in the wild and is it delivering on its promises to make us more productive, our software more reliable, and our products less error-prone?

It’s hard to get a read on this with any degree of certainity, although some case studies would suggest it is returning dividends, but I would posit that most developers do not get the most bang for their buck when using TypeScript.

I hope this article can give you a slight edge as I share how I, here at Administrate, use TypeScript to deliver value reliably.

Setup

Before we get started, let’s get our local environment setup with a terminal, code editor, and of course TypeScript and Node:

To follow along, either download the code from here or follow the setup below:

mkdir typescript-library-app
cd typescript-library-app
pnpm init
pnpm add chalk inquirer
pnpm add -D typescript @types/inquirer @types/node ts-node

The tsconfig is very minimal, the basic gist is we want modern JS feature (es2022) and to use NodeJS Native ESM (NodeNext), and have a strict typing experience so TS is doing the most work for us. We will emit our transpiled JS to the dist directory.

tsconfig.json

{
  "compilerOptions": {
    "lib": ["esnext"],
    "outDir": "dist",
    "target": "es2022",
    "module": "NodeNext",
    "strictFunctionTypes": true,
    "strictNullChecks": true,
    "noFallthroughCasesInSwitch": true,
    "alwaysStrict": true
  },
  "include": ["./src/**/*.ts"],
  "exclude": ["node_modules", "__tests__", "dist"]
}

And we’ll use a minimal set of scripts to develop with TS + Node

package.json > scripts

{
  "scripts": {
    "start": "node dist/index.js",
    "build": "tsc",
    "src": "ts-node --esm src/index.ts"
  }
}

Modelling

TypeScript allows us to model our data structures and both our function parameters and return types. With a few critical type annotations, inference can take over to allow the types to flow through our applications. It’s recommended that we aim to keep type annotations as minimal as possible in application code, and instead rely on inference. This will keep us the most honest. That doesn’t mean we throw out annotations, but rather, we type the complex edges and boundaries of our apps to let TypeScript analyze our codebase.

Let’s imagine we’re running a brick-and-mortar library and our librarians require a CLI app to manage their inventory.

Let’s start by modeling a book:

interface Book {
  id: string;
  title: string;
  author: string;
}

Now let’s see if we can model an id more strictly by using Template Literal Types, let’s assume instead of ISBN (because ISBN can be modeled by just a number within a range to fit 10 or 13 digits) our book id uses a custom format like this:

pattern:
  2_DIGIT_HEX-INTEGER

ex:
  ef-24200
  9b-1
  00-0
  fe-195
  ff-999999

Then the typescript could be, with some helper types:

const validChars = [
  "a",
  "b",
  "c",
  "d",
  "e",
  "f",
  "0",
  "1",
  "2",
  "3",
  "4",
  "5",
  "6",
  "7",
  "8",
  "9",
] as const; // < - - as const is important to infer the literal!

// by indexing into the type of the array literal we get a union
// of all elements 
type HEX_CHAR = typeof validChars[number];

// We can use the unions in a type template to construct all possible two-digit hex strings
type HEX_PREFIX = `${HEX_CHAR}${HEX_CHAR}`;

type BookId = `${HEX_PREFIX}-${number}`;

const makeBookId = (hex: HEX_PREFIX, num: number): BookId =>
  `${hex}-${num}`;

interface Book {
  id: BookId;
  // ... the rest...
}

Now if we try to do something like:

makeBookId('as', 20);

we’ll get a compiler error because ‘s’ is not a valid hex digit:

Type '"as"' is not assignable to type "aa" | "ab" ...snip - ts enumerates all possible values...

Validations

Before we make our CLI app we’ll need some helper validation functions that can take in unvalidated inputs, validate them, and assert they are the new type. This is a type predicate in TS. We’ll move some types around so we can derive our types from the list of valid characters.

// For simplicity we start with true/false, but later on in the codebase
//   we'll refactor to throw Errors from here. For now we let callers throw.

export function isHex(input: any): input is HEX_PREFIX {
  if (typeof input !== "string") {
    return false;
  }

  if (input.length !== 2) {
    return false;
  }

  if (!validChars.includes(input[0] as any)) {
    return false;
  }

  if (!validChars.includes(input[1] as any)) {
    return false;
  }

  return true;
}

export function isId(input: any): input is BookId {
  if (typeof input !== "string") {
    return false;
  }

  if (input.length < 3) {
    return false;
  }

  if (!input.includes("-")) {
    return false;
  }

  const [hex, num] = input.split("-");
  if (!hex || !num) {
    return false;
  }

  if (!isHex(hex)) {
    return false;
  }

  return isPositiveInt(num);
}

export function isPositiveInt(input: any): input is number {
  if (typeof input === "string") {
    const int = parseInt(input);

    if (int > 0) {
      return true;
    }
  }

  return false;
}

We’re going to have an in-memory inventory of our books and we’ll encapsulate our core library domain logic in a library.js file, not to be confused with a programming library - this is an irl library full of books! In addition to the types, helpers, and validation above we can store our state. Books can be checked out or not, so we can model that with a simple array and a Set.

library.js

// internal module state
const checkedOutBooks = new Set<BookId>();
const bookList: Book[] = [];

// Expose API for our CLI to consume
export const Inventory = {
  add(book: Book) {
    bookList.push(book);
  },
  count() {
    return bookList.length;
  },
  isCheckedOut(id: BookId) {
    return checkedOutBooks.has(id);
  },
  checkoutById(id: BookId) {
    checkedOutBooks.add(id);
  },
  returnById(id: BookId) {
    checkedOutBooks.delete(id);
  },
  search(searchTerm: string) {
    const searchPattern = new RegExp(searchTerm, "i");

    const foundBook = bookList.find((book) => {
      const titleMatch = book.title.match(searchPattern);
      const authorMatch = book.author.match(searchPattern);
      const idMatch = book.id.match(searchPattern);

      if (idMatch) {
        return true;
      }
      if (titleMatch) {
        return true;
      }
      if (authorMatch) {
        return true;
      }
    });

    if (foundBook) {
      return foundBook;
    }

    return null;
  },
};

CLI

Now our CLI app will allow simple data entry, giving validation errors. We’ll use inquirer to get some helper cli functions. We’re only going to have a few options for our librarians to choose from, so we can enumerate them in another const array literal, and have a union we can use in type annotations:

import inquirer, { ListQuestion } from 'inquirer';
import { Inventory } from './library.js'; // we're using native NodeJS ESM so add the .js extension

const ACTIONS = [
  "Add a Book",
  "Checkout a Book",
  "Return a Book",
  "Check Book State",
  "Search for a Book",
  "Count Books",
  "Clear Terminal",
  "Exit",
] as const;
type ACTION_TYPES = typeof ACTIONS[number];

const TOP_LEVEL_PROMPT: ListQuestion<{ action: ACTION_TYPES }> = {
  type: "list",
  name: "action",
  message: "What would you like to do",
  choices: ACTIONS,
}

// This loop is the beating heart of our app
while(true) {
  const { action } = await cli.prompt([TOP_LEVEL_PROMPT]);

  await dispatch(action);
}

Exhaustive Switch Case Checking

The authoring experience of writing dispatch will already start to showcase how using TypeScript with a stricter set of configurations and using these helper types like the constant literals will force us to do the right thing.

Let’s try implementing this dispatch function. We are awaiting the dispatch because the CLI app inquirer models input as a promise (ie you await the user to provide valid input). The actions our library takes are often all synchronous, but it’s useful to have a promise here if we need any more inputs from the user.

The type of dispatch then will take one of the known action types, and return a void promise (ie a promise that returns nothing).

function dispatch(actionName: ACTION_TYPES): Promise<void> {
  // ...
}

Since we have a small, known set of string keys, a switch is a perfect tool to reach for. And TypeScript will make sure we cover all known cases, otherwise it will error! And since it knows we can cover all the cases, we won’t need to define a default branch, because adding a new branch is a compiler error. We’ll demonstrate that here:

function dispatch(actionName: ACTION_TYPES): Promise<void> {
  switch (actionName) {
    case "Count Books": {
      successMessage(`Library has ${Inventory.count()} books`);
      return Promise.resolve();
    }
  }
}

Now we’re getting an error because we don’t have any of the other branches. The Function doesn’t always return what it says it will:

Function lacks ending return statement and return type does not include 'undefined'.

Let’s stub the rest of the branches. If we go back to a bare switch, the TypeScript LSP will let our editor auto-complete everything in one go:

switch (actionName) {
  case "Add a Book":
  case "Checkout a Book":
  case "Return a Book":
  case "Check Book State":
  case "Search for a Book":
  case "Count Books":
  case "Clear Terminal":
  case "Exit":
}

So now we can fill in all the implementations, knowing it won’t compile until we’re done. Since the dispatch function should be responsible only for one thing, we can call out to and create functions that fulfill the type contract.

function dispatch(actionName: ACTION_TYPES): Promise<void> {
  switch (actionName) {
    case "Add a Book":
      return addBook();
    case "Checkout a Book":
      return checkoutBook();
    case "Return a Book":
      return returnBook();
    case "Check Book State":
      return checkBookState();
    case "Search for a Book":
      return searchForBook();
    case "Count Books":
      successMessage(`Library has ${Inventory.count()} books`);
      return Promise.resolve();
    case "Clear Terminal":
      console.clear();
      return Promise.resolve();
    case "Exit":
      successMessage("Good bye 👋  (your library inventory will be purged)");
      process.exit(0);
  }
}

If you define all of these functions with stub implementations, it will compile fine, yay! Reviewing the code, you may be tempted to do a refactoring like:

function dispatch(actionName: ACTION_TYPES): Promise<void> {
  switch (actionName) {
    case "Add a Book":
      return addBook();
    case "Checkout a Book":
      return checkoutBook();
    case "Return a Book":
      return returnBook();
    case "Check Book State":
      return checkBookState();
    case "Search for a Book":
      return searchForBook();
    case "Count Books": // error!
      successMessage(`Library has ${Inventory.count()} books`);
    case "Clear Terminal": // error!
      console.clear();
    case "Exit":
      successMessage("Good bye 👋  (your library inventory will be purged)");
      process.exit(0);
  }

  return Promise.resolve();
}

But, luckily, TypeScript knows better here. It isn’t a good idea to fallthrough these switch cases as you may unintentionally allow a fallthrough to happen from a case that should be handled differently. Our tsconfig setting noFallthroughCasesInSwitch protects us against this mistake, and keeps this function clean and tidy to boot.

Here’s an example of implementing the prompts and cli app logic for something like adding a book, the rest is in the GitHub repo:

const BOOK_ID_HEX_PROMPT: InputQuestion<{ idPrefix: string }> = {
  type: "input",
  name: "idPrefix",
  message: "Book ID Prefix: ",
  validate(input: string) {
    const cleanInput = input.trim().toLowerCase();
    if (isHex(cleanInput)) {
      return true;
    }

    throw new Error(
      "Invalid hex prefix, please provide two hex characters (0-9a-f)"
    );
  },
};

const BOOK_ID_PROMPT: InputQuestion<{ id: string }> = {
  type: "input",
  name: "id",
  message: "Book ID Number: ",
  validate(input: string) {
    if (isPositiveInt(input.trim())) {
      return true;
    }

    throw new Error("Invalid number, please pass a valid positive integer");
  },
};

const BOOK_TITLE_PROMPT: InputQuestion<{ title: string }> = {
  type: "input",
  name: "title",
  message: "Book Title: ",
};

const BOOK_AUTHOR_PROMPT: InputQuestion<{ author: string }> = {
  type: "input",
  name: "author",
  message: "Book Author Name: ",
};

async function addBook() {
  const answers = await cli.prompt<{
    title: string;
    author: string;
    id: string;
    idPrefix: string;
  }>([
    BOOK_ID_HEX_PROMPT,
    BOOK_ID_PROMPT,
    BOOK_TITLE_PROMPT,
    BOOK_AUTHOR_PROMPT,
  ]);

  const { idPrefix, id, title, author } = answers;

  const cleanPrefix = idPrefix.trim().toLowerCase();

  // we know this will pass because of the validation from the prompt,
  // but doing this gets us type narrowing from the type predicate
  if (isHex(cleanPrefix)) {
    Inventory.add(
      makeBook(
        makeBookId(cleanPrefix, parseInt(id.trim())),
        title.trim(),
        author.trim()
      )
    );
    successMessage(`${title} added.`);
  }
}

Putting it all together gives us a CLI app experience like this:

Example cli app running gif from

More Type Safety

We were able to leverage type safety around our core ID and Book types, and even our dispatch function, but we’re having to redo some work to get type narrowing working properly with this untyped CLI app, see here:

async function addBook() {
  const answers = await cli.prompt<{
    title: string;
    author: string;
    id: string;
    idPrefix: string;
  }>([
    BOOK_ID_HEX_PROMPT,
    BOOK_ID_PROMPT,
    BOOK_TITLE_PROMPT,
    BOOK_AUTHOR_PROMPT,
  ]);

  const { idPrefix, id, title, author } = answers;

  const cleanPrefix = idPrefix.trim().toLowerCase();

  if (isHex(cleanPrefix)) { // < --- can we get this "for free" because we already validated?
  // ... snip ...

Even though we have a typings package alongside this app, because it isn’t ‘TypeScript native’ issues like this can occur where we may have to re-introduce types we already gave the library, can we remove the need to do this redundant isHex call?

Yes! But we will need to wrap inquirer to do so, providing a more type-safe and validation-aware API to inquirer. We could explore adding something like zod to our stack here, but since inquirer isn’t ‘TS native’, we would have to do the same amount of work anyway with our existing type predicates.

Mapped Types

Let’s try to leverage our type predicates in a new cli.ts file so that when we prompt, we get type inference.

First let’s try to create a stricter shape for input, something like:

cli.ts

interface TypeSafeInput

We will want two type parameters, one is the validated type after we use our type predicate, and the other is the name of the input for inquirer, which we will wrap.

interface TypeSafeInput<
  TValidatedInput,
  TName extends string
> extends InputQuestion<{ Something: TValidatedInput }>

I’ve added Something in the return type generic because we want the string we use for the name to flow into InputQuestion, so naively we may want to use an index type with TName to do that:

interface TypeSafeInput<
  TValidatedInput,
  TName extends string
> extends InputQuestion<{ [TName]: TValidatedInput }>

This doesn’t work, because the TypeScript syntax needs a unique symbol here. We know TName has to be a string because of our generic constraint extends string, so what we can do is get the keys in this Type to get to the underlying string as a computed key for our resulting object, the docs for that are in the Mapped Types section and use key in T syntax. The Docs use keyof because they are working with Types that are not keys themselves, but in our case we know it is a key, so we can just map over it with in and bind the string to the key intermediate name (that can be named anything, key is just convention):

interface TypeSafeInput<
  TValidatedInput,
  TName extends string
> extends InputQuestion<{ [key in TName]: TValidatedInput }>

It might make more sense to rename key to name in our case:

interface TypeSafeInput<
  TValidatedInput,
  TName extends string
> extends InputQuestion<{ [name in TName]: TValidatedInput }>

This will reinforce that the special sauce is the in operator, not the left-hand side type variable name.

The actual implementation is fairly straightforward:

interface TypeSafeInput<
  TValidatedInput,
  TName extends string
> extends InputQuestion<{ [name in TName]: TValidatedInput }> {
  type: "input";
  name: TName;
  message: string;
  parse: (input: any) => TValidatedInput;
}

If we try to introduce the typePredicate’s parameter here we end up duplicating the fact that InputQuestion already has a validation function, we can make a convenience function to create our type safe input to make this easier and still accept a proper type predicate, but we’ll name it validate for convenience.

If there is any separate parsing work from the type predicate, we can do those after in a function that post processes inquirer’s prompt function. In away this supports the existing API of inquirer anyway.

Here’s a helper create function for our type safe input:

// We don't always need to do anything after the booleanp predicate is true,
// but other times we do need to parse the string to another type.
const identity = <T>(x: T) => x;

export const createInput = <T, N extends string>(
  name: N,
  message: string,
  validate: (input: any) => input is T,
  parse?: (input: any) => T
): TypeSafeInput<T, N> => ({
  type: "input",
  name,
  message,
  validate,
  parse: parse || identity
});

Now let’s create our own prompt function to get more type safety in our functions:

export const prompt = async <TI, TN extends string>(input: TypeSafeInput<TI, TN>) => {
  const answer = await cli.prompt(input);
  return input.parse(answer[input.name]);
}

Inference Sets You Free

Now we can use the power of type inference to get some nice DX, let’s try out this new prompt on an integer and our two-character hex prefix:

const testHexPrompt = createInput("hex", "Give me 2 hex digits", isHex);
const testAgePrompt = createInput("age", "Give me your age", isPositiveInt, parseInt);

const testHex = await prompt(testHexPrompt);
const testAge = await prompt(testAgePrompt);

Hovering over testHex and testAge shows that we get full type inference, even for fancy types like our Hex Prefix! And we can pass type predicates and parser functions directly to our createInput helper. Here we’ve named both of these "test" because we’re only doing them one at a time, so let’s see if we can support an array of type-safe inputs and get TypeScript to & them all together.

hex on hover age on hover

First, let’s just try to make a prompt function that takes in an array and sprinkle in anys and we’ll try to tackle the types one by one:

export async function prompt(inputs: TypeSafeInput<any, any>[]) {
  const answers = await cli.prompt(inputs);

  return inputs.reduce((accum, curr) => {
    const parsedValue = curr.parse(answers[curr.name]);
    accum[curr.name] = parsedValue;
    return accum;
  }, {} as any);
}

We’re essentially doing the same thing, but using reduce on the input array and building an object with the keys mapped to the parsed values. Now to tackle the types, if we provide the minimum generics first:

export async function prompt<T1, TN1 extends string>(inputs: TypeSafeInput<T1, TN1>[]) {
  const answers = await cli.prompt(inputs);

  return inputs.reduce((accum, curr) => {
    const parsedValue = curr.parse(answers[curr.name]);
    accum[curr.name] = parsedValue;
    return accum;
  }, {} as any);
}

We know we expect a simple object with TN1: T1 so let’s write a quick couple of helper type aliases:

type SimpleObject<TKey extends string, TValue> = { [name in TKey]: TValue };
type SO<TK extends string, TV> = SimpleObject<TK, TV>;

And throw that in the return type:

export async function prompt<T1, TN1 extends string>(inputs: TypeSafeInput<T1, TN1>[]): Promise<SO<TN1, T1>> {
  const answers = await cli.prompt(inputs);

  return inputs.reduce((accum, curr) => {
    const parsedValue = curr.parse(answers[curr.name]);
    accum[curr.name] = parsedValue;
    return accum;
  }, {} as any);
}

Now the reducer’s object initialization can be better typed as we know the result is a simple object:

export async function prompt<T1, TN1 extends string>(...inputs: TypeSafeInput<T1, TN1>[]): Promise<SO<TN1, T1>> {
  const answers = await cli.prompt(inputs);

  return inputs.reduce((accum, curr) => {
    const parsedValue = curr.parse(answers[curr.name]);
    accum[curr.name] = parsedValue;
    return accum;
  }, {} as SO<TN1, T1>);
}

Function Overloading

This implementation works for any size array, but the typings won’t be right. We can provide overloads, reusing the same implementation, but expanding on the number of parameters with tuple types. This is how some utility libraries like lodash work under the hood. It may seem tedious, but the copy-pasta mostly completes itself.

export async function prompt<
  T1, TN1 extends string
>(
  i1: TypeSafeInput<T1, TN1>
): Promise<SO<TN1, T1>>;

Now we copy this same overload type definition for T2 through to the T4 variants, completing the pie 🥧.

export async function prompt<
  T1, TN1 extends string,
  T2, TN2 extends string,
  T3, TN3 extends string,
  T4, TN4 extends string
>(
  i1: TypeSafeInput<T1, TN1>,
  i2: TypeSafeInput<T2, TN2>,
  i3: TypeSafeInput<T3, TN3>,
  i4: TypeSafeInput<T4, TN4>
): Promise<SO<TN1, T1> & SO<TN2, T2> & SO<TN3, T3> & SO<TN4, T4>>;

export async function prompt<
  T1, TN1 extends string,
  T2, TN2 extends string,
  T3, TN3 extends string,
>(
  i1: TypeSafeInput<T1, TN1>,
  i2: TypeSafeInput<T2, TN2>,
  i3: TypeSafeInput<T3, TN3>
): Promise<SO<TN1, T1> & SO<TN2, T2> & SO<TN3, T3>>;

export async function prompt<
  T1, TN1 extends string,
  T2, TN2 extends string,
>(
  i1: TypeSafeInput<T1, TN1>,
  i2: TypeSafeInput<T2, TN2>
): Promise<SO<TN1, T1> & SO<TN2, T2>>;

export async function prompt<
  T1, TN1 extends string,
>(
  i1: TypeSafeInput<T1, TN1>
): Promise<SO<TN1, T1>>;

export async function prompt<T1, TN1 extends string>(...inputs: TypeSafeInput<T1, TN1>[]): Promise<SO<TN1, T1>> {
  const answers = await cli.prompt(inputs);

  return inputs.reduce((accum, curr) => {
    const parsedValue = curr.parse(answers[curr.name]);
    accum[curr.name] = parsedValue;
    return accum;
  }, {} as SO<TN1, T1>);
}

At this point, we can start refactoring our app to use this new prompt! The types of our predicates and parsers will flow out of each prompt invocation so redundant type narrowing or checking is unnecessary now. This is particularly helpful for the add book flow:

const BOOK_ID_HEX_PROMPT = createInput("idPrefix", "Book ID Prefix: ", isHex, cleanHex);
const BOOK_ID_PROMPT = createInput("id", "Book ID Number: ", isPositiveInt, parseInt);
const BOOK_TITLE_PROMPT = createInput("title", "Book Title: ", isNonEmptyString);
const BOOK_AUTHOR_PROMPT = createInput("author", "Book Author: ", isNonEmptyString);

async function addBook() {
  const { idPrefix, id, title, author } = await prompt(
    BOOK_ID_HEX_PROMPT,
    BOOK_ID_PROMPT,
    BOOK_TITLE_PROMPT,
    BOOK_AUTHOR_PROMPT
  );

  Inventory.add(
    makeBook(
      makeBookId(idPrefix, id),
      title.trim(),
      author.trim()
    )
  );
  successMessage(`${title} added.`);
}

It also makes defining all our prompts one-liners which is a nice bonus. After trawling through the codebase, we can remove some imports, make sure errors are being thrown from the type predicates and validators, and move our parsers around a bit to ensure we get the right types all while the compiler is there checking our work.

One of the biggest challenges when working on the web platform is just knowing what’s going on and what the shape of things is going to be, whether that’s data over the wire or even the libraries that are supposed to accelerate our productivity. Just sprinkle of TypeScript around a library function like cli.prompt can go a long way to supporting our applications.

Now that we’re confident in our app, can we add a feature easily?

Persistence

Our librarians are delighted with the app but hate when their cli session is closed and they lose the entire library. They started asking for backup/restore actions, but they just want the state persisted to disk. Let’s do that!

First, we need to wrap the state into one object/interface, and create some helper functions for the JSON functions:

interface State {
  books: Book[],
  checkedOutBooks: Set<BookId>
};

let state: State = {
  books: [],
  checkedOutBooks: new Set()
};

function replacer(key: string, value: string) {
  if (key === "checkedOutBooks") {
    return Array.from(value);
  }

  return value;
}

function reviver(key: string, value: string) {
  if (key === "checkedOutBooks") {
    if (Array.isArray(value)) {
      return new Set(value);
    }

    throw new Error("Invalid state, checkedOutBooks must be saved as an array");
  }

  return value;
}

Now that this boilerplate is out of the way, we can write the serialize and restore functions:

import { readFile writeFile } from "fs/promises";

const FILE_PATH = "state.data";

function saveState() {
  const stringifiedState = JSON.stringify(state, replacer);
  const utf8Buff = Buffer.from(stringifiedState, "utf-8");
  const base64EncodedStateString = utf8Buff.toString("base64");

  return writeFile(FILE_PATH, base64EncodedStateString);
}

async function restoreState() {
  try {
    const file = await readFile(FILE_PATH)
    const b64String = file.toString();
    const str = Buffer.from(b64String, "base64").toString("utf-8");

    state = JSON.parse(str, reviver)

    return state;
  } catch (err) {
    return state;
  }
}

restoreState();

And we just update all instances in the library.js that access state, and boom, we have stored the state to disk in a base 64 encoded file. TypeScript wasn’t the most helpful, but it does make sure we’re using Buffer’s and strings correctly, which is an easy mistake when using these node APIs.

What’s next?

I highly recommend the following places to dig deeper into TypeScript and type safe web application development in the JS ecosystem: