« Scott Nonnenberg


A Typescript onramp

2021 Nov 28

So you’re a Javascript developer, and you want to stop writing Javascript. Typescript seems like a good option, but you’ve always used dynamically-typed languages. You’re uncertain about jumping in, or maybe don’t feel like you aren’t using it to its potential. I think I can help. Let’s ramp you into Typescript!

Progressive enhancement

The first thing to know about Typescript is that you can start using it today! There’s no need to rename all of your files to .ts and laboriously ‘typescriptify’ all at once. With its checkJs option enabled, Typescript can analyze and find potential errors in plain Javascript files.

For example, for a Node.js project, you can add core types like this:

yarn add --dev typescript
yarn add --dev @types/node

Then add a basic tsconfig.json file like this:

{
  "compilerOptions": {
    "checkJs": true,
    "baseUrl": "./",
  },
  "include": ["./**/*.js"],
}

Then you can run Typescript checks, with no generation of compiled Javascript, like this:

yarn tsc --noEmit

You’ll probably get more complaints here, as Typescript finds components you’re using which have no type definitions. You likely find types for these other modules on DefinitelyTyped. If you can’t, you can throw a // @ts-ignore comment right above the offending dependency, and the compiler will stop complaining.

You might be wondering: why does it need these type definitions, if it’s just Javascript? Well, just like if it were analyzing your Typescript, it needs to know the structure of your dependencies. For example, in this tiny script, it will catch that extra third parameter passed to readFileSync:

const { readFileSync } = require('fs');

const json = readFileSync('package.json', 'utf8', 'utf8');
const data = JSON.parse(json);
console.log(data);

const text = JSON.stringify(data);
const weird = text * 5;
console.log(weird);

const reallyWeird = data * 5;
console.log(reallyWeird);

And it will also catch that text * 5 clause, because it knows the result of JSON.stringify() is a string, and a string can’t be multiplied by five without strange results. This is our first example of type inference, where Typescript is able to determine that the text variable is most certainly a string, given how it was initialized.

Next, try getting some more value out of these Javascript checks by enabling a few more compiler options. You could also use JSDoc annotations to get more value out of your plain Javascript checks, but I’d recommend that you move all the way to .ts files at this point.

The any escape valve

Note that Typescript didn’t catch the data * 5 clause in the above code, which similarly produced NaN. That’s because the return value of JSON.parse() is any, a special value which tells Typescript to let you do anything with that value. Call a function on it, multiply it, use it like an array - whatever!

And it turns out that’s how // @ts-ignore works with those other dependencies without type definitions. This is your escape valve. As you start to translate a given Javascript file to Typescript, you can use any to defer the need for fully-rigorous types.

I certainly don’t recommend using it a lot, but it’s sometimes a needed tool.

All this, and we haven’t written a line of Typescript so far!

What is a type?

Before we can write Typescript, we need to start at the very beginning. A type, in Javascript, is the kind of data stored in a given variable or expressed in a given constant. So the type of a variable can change, radically:

var count = 4;
count = 3;
count = 2;
count = 1;
count = 'Ah ah ah ah!';
count = {
  name: 'The Count',
  catchPhrase: 'Ah ah ah ah!'
  type: 'puppet'
};

This single variable went from a number, to a string, to a more complex object. This is dangerous, because most code isn’t ready to deal with all three forms of this variable. Statically-typed languages like Typescript help you avoid this problem. At compile-time, the type of the variable is known either via explicit type definitions or via contextual type inference.

Given that type understanding, the compiler helps keep you honest. No, you can’t increment the value of "The Count". But you can increment the value of 5.

You can think of static type-checking like setting a budget for your finances. Writing plain Javascript is like forgetting your budget or having none at all - you just spend and spend and spend. You can very easily overdraft your account, spending more than you should. Typescript is like telling a robot your intended budget - it then automatically checks your spending as you do it, loudly letting you know if things are not in good shape.

With Typescript, you put in effort up front, declaring types and fixing type issues, and you’re less likely to have bugs in your code. You also have a form of documentation in the type annotations on your functions. And perhaps most importantly, change is easier in the future. For example, when you add a new required field to the User object, the compiler will show errors wherever you create User objects, guiding you towards the complete change.

Type definitions

Now we can start writing our first Typescript. Let’s dig into some type definitions. I’ve added code for each section to the Typescript Playground - click to follow along!

Here we tell the compiler that the variable givenName is a string. Later, if you try to assign it to a number the compiler will complain:

let givenName: string;

You can start to compose smaller building blocks into larger objects. This User object has a few name fields which are strings, as well as an age field which is a number. Its id field is readonly and cannot be changed once set, enforced by the Typescript compiler. Its familyName field is optional, denoted by the question mark. This means that user.familyName can either be a string, undefined, or not a key in the object at all!

type User = {
  readonly id: string;
  givenName: string;
  familyName?: string;
  age: number;
};

You can also express modality like this, using a union type - it can only be one of the chosen two values:

let familyName: string | undefined;

Union types can get a lot more interesting. The compiler won’t allow us to assign anything to the eyeColor variable but those exact values. We can’t even assign a plain string to eyeColor because it might not be one of those five values!

let eyeColor: 'brown' | 'green' | 'blue' | 'hazel' | 'gray';

Now we’re starting to build up types describing more interesting structures:

type BasicLogger = {
  info: (text: string) => void;
  warn: (text: string) => void;
  error: (text: string) => void;
  getLog: () => string;
  _lines: string[];
};

If you get a variable of type BasicLogger you can call four different functions on it. Three return nothing (void means ‘no return value’), and take one parameter, text, a string. The fourth takes no parameters and returns a single string, the results of all of your logging so far. These are hard requirements, by the way - you can’t call these functions with missing or mismatched parameters.

You can also go directly to the logger’s _lines field and get the lines before they are assembled into the final string with getLog(). The [] addition to the type signifies that it’s an array of that type.

There’s one final bit of syntax you’ll need to know to be able to describe the vast majority of Javascript data structures:

type UserMap = {
  [lookupKey: string]: User | undefined;
};

We wanted to look up users really fast by their id, so a UserMap has dynamic keys that represent a user id, which point to full User objects. Sometimes. Because we can’t be guaranteed that every string we provide to this object will return a User. But a lot of APIs are set up this way, a lot of Javascript objects are designed to be used like this.

type UserMap = Record<string, User | undefined>;

That object structure is so common, in fact, that there’s a built-in Typescript utility type called Record which allows you to very succinctly declare the same UserMap type.

Generics

With Record you saw a new syntax: < and > surrounding other types. This is the concept of generics. Let’s start with a simpler example.

An array has a certain shape in Javascript - length, access to individual elements with [], push()/pop(), sort() and so on. And we saw above that you can declare an array of numbers with number[]. But there’s another option (Typescript playground):

const numbers: Array<number> = [];

Here we’ve created a numbers variable, an array that will only hold numbers. It’s in the shape of an array but with a number type parameter put everywhere an array element would go. For example, instead of any value, pop() now gives us a number | undefined. A number if there’s something in the array, undefined if there’s nothing in the array.

You can use generic type parameters as you declare things as well. We’ve declared our own generic function here, which will create a new array of the chosen type:

function makeNewArray<T>(): Array<T> {
  return new Array<T>();
}

We have the choice of explicitly providing the generic type parameter when we call this function, or we can rely on the Typescript compiler’s type inference of what the parameter should be. Take a look at this code:

const rightStrings = makeNewArray<string>();
const leftStrings: Array<string> = makeNewArray();

const booleans = makeNewArray<boolean>();
const users = makeNewArray<User>();

Note that we don’t need to declare the type on both sides of the =. Choose one, and the other will be inferred. And of course, we can provide any type we want as the T.

Let’s look at a more complex example. Here we’ve written a function that takes another async function, calls it and returns its value back. If an error is thrown, it will print that out before throwing that error again:

async function callAndLogError<T>(fn: () => Promise<T>): Promise<T> {
  try {
    return await fn();
  } catch (error) {
    if (error instanceof Error) {
      console.log('callAndLogError: Something went wrong!', error.stack);
    }
    throw error;
  }
}

The reason the <T> is useful here is that we can call callAndLogError() with any function taking no parameters and returning a promise, and that function’s return type will flow back out. No loss of type fidelity, even as we do complex things with our functions, and that means that the compiler can better verify our code!

Like our makeNewArray() function above, generic type parameters provide ‘type hints’ in many situations where you’re using libraries or utility functions. If you’re writing React, some of these might be familiar:

const userLookup = new Map<string, User>();
const userIdList = new Set<string>();

class UpdateNameForm extends React.Component<PropsType, StateType> { ... }
const buttonRef = React.createRef<HTMLButtonComponent>();
const [name, setName] = React.useState<string | undefined>("Someone Somewhere");

In each of these cases, the generic type parameters allow the compiler to give us the checking we expect as we use these variables downstream. In the case of React.Component it allows the typescript compiler to ensure that we’ve properly implemented all of the necessary elements of a React Component, with the right props and state shapes.

Libraries and interface

While we’re on the topic of libraries, I want to make something very clear: every library is its own adventure. It’s easy to get caught up in the errors, thinking that you’ve done something wrong, or don’t know Typescript well enough. But in many cases it’s the library.

A given library may have crisp, comprehensive type definitions, or it might have lots of any. It might even have errors in its types, and working code will be flagged by the compiler. Or, as we’ve discussed, you may find a library with no type definitions at all.

This brings us to the interface keyword. The truth is that interfaces are used a lot like the type keyword above, just with no = character. Here’s the same User object from before, declared as an interface (Typescript playground):

interface User {
  readonly id: string;
  givenName: string;
  familyName?: string;
  age: number;
}

Now, why would you want to use an interface? The answer is declaration merging. Watch what happens if we put another interface User clause into this file - now that User interface has another required field:

interface User {
  eyeColor: 'brown' | 'green' | 'blue' | 'hazel' | 'gray';
}
const user: User = {
  id: 'id3',
  givenName: 'Someone',
  age: 32,
  eyeColor: 'green',
}

Declaration merging is how you fix types from a library you’re using, or provide your own types from scratch, or even add a few fields to window. Because most exported library types and just about all built-in type definitions are interfaces.

It turns out that augmenting a library and providing your types from scratch look the same. broken-link-checker is a module my blog uses to check for broken links, and it doesn’t have type definitions. These are the minimal types needed to make my code compile:

declare module 'broken-link-checker' {
  export interface SiteCheckerResultType {
    url: {
      resolved: string;
    };
  }

  interface OptionsType {
    excludeExternalLinks: boolean;
  }
  interface HandlersType {
    link?: (result: SiteCheckerResultType) => void;
    end?: () => void;
  }

  export class SiteChecker {
    constructor(options: OptionsType, handlers: HandlersType);
    enqueue: (domain: string) => void;
  }
}

Augmenting globals requires a slightly different syntax. You can extend window, Array, HTMLButtonElement but I suggest that you keep it to window, and keep it to a minimum!

declare global {
  interface Window {
    fetchUser: () => Promise<User>;
  }
}

It’s worth it to add type definitions like this, because the alternative is to leave an any in the code. And an any means that you’re getting very little checking! It’s almost like writing plain Javascript, and we don’t want that!

Exhaustive checks and never

We’ve talked about a lot of the basic building blocks of working with Typescript. Now let’s talk about a particularly cool kind of validation that Typescript can do for you: exhaustive checks.

Let’s go back to eye color (Typescript Playground):

type EyeColor = 'brown' | 'green' | 'blue' | 'hazel' | 'gray';

function processEyeColor(color: EyeColor): number {
  if (color === 'brown') {
    return 1;
  } else if (color === 'green') {
    return 2;
  } else if (color === 'blue') {
    return 3;
  } else if (color === 'hazel') {
    return 4;
  } else if (color === 'gray') {
    return 5;
  } else {
    const problem: never = color;
    throw new Error(`Found unknown eye color: ${problem}`);
  }
}

First, we’ve created a type alias here, giving the convenient name of EyeColor to the type we declared inline previously.

Next, processEyeColor checks against all potential values of the EyeColor type, and then throws an error if it finds something unexpected.

The key to ensuring that this set of checks is exhaustive, checking for all potential values of EyeColor, is this new keyword never. This is a special value which indicates that there should be an error if the Typescript compiler believes that we could ever get to that line.

With this construction, the compiler guides us as we make changes to this code - if we add a new value to the EyeColor type, we’ll get errors until processEyeColor gets another check ensuring that it remains exhaustive!

Once more we return to type inference. Typescript keeps track of the potential values of variables through the flow of your functions. At the top of the function, that color variable could have five values, and then with each successive comparison, those potential values narrow until no values are possible, or never. To get a feel for this, hover over variables in Typescript Playground or in VSCode and follow along.

Runtime types and unknown

With the exhaustive checks above, you might be wondering: couldn’t a value other than EyeColor somehow get into that variable? Sure you could present a <select> to the user to constrain their input, but what if an old version of the app had more options? Then your types don’t describe reality!

One choice might be to relax your types to prevent any strong assumptions about their shape. But this gets dangerous when you start loading JSON from disk, pulling JSON from web APIs, pulling JSON from your MongoDB databases. Three are too many sources of questionable data!

There’s another option: runtime validation of your compile-time types. If you’ve used hapi, you’ve used joi to write schemas for validation of your incoming and outgoing data. And you’re maybe thinking that you don’t want to define Typescript types and joi types. And I agree - that’s tedious.

To solve that, I suggest a library called zod. It allows you to define runtime types using its API, which are then available to Typescript for compile-time validation. Here’s the same code to process eye color from above, with both runtime and compile-time guarantees that our code works (Typescript Playground):

import { z } from 'zod';

const eyeColorSchema = z.enum(['brown', 'green', 'blue', 'hazel', 'gray']);
type EyeColor = z.infer<typeof eyeColorSchema>;

export function processEyeColor(incoming: unknown): number {
  const parsed = eyeColorSchema.safeParse(incoming);
  if (!parsed.success) {
    throw new Error(`processEyeColor: Failed to parse eye color: ${parsed.error.flatten()}`);
  }

  const color = parsed.data;

  if (color === eyeColorSchema.enum.brown) {
    return 1;
  } else if (color === eyeColorSchema.enum.green) {
    return 2;
  } else if (color === eyeColorSchema.enum.blue) {
    return 3;
  } else if (color === eyeColorSchema.enum.hazel) {
    return 4;
  } else if (color === eyeColorSchema.enum.gray) {
    return 5;
  } else {
    const problem: never = color;
    throw new Error(`Found unknown eye color: ${problem}`);
  }
}

The key is the z.infer call which allows Typescript to use type inference to give you access to the type you declared in code. It’s the same set of values as before, but zod gives us access to the values at runtime as well (like eyeColorSchema.enum.brown).

We use unknown as the parameter of processEyeColor, because we have no idea where this data came from, and we want Typescript to force us to validate it before we use it. unknown is the opposite of any - you can’t use it until you’ve explicitly verified its shape. Here we could dispense with the parsing and do direct === checks against the unknown value, but then we’d lose our exhaustiveness guarantee.

That’s why we use our schema to parse the value into our expected type. safeParse takes unknown and returns a discriminated union type:

{ success: true; data: T; } | { success: false; error: ZodError; }

These kinds of unions take advantage of Typescript type narrowing. If success is true, we know that we have a data field of the type we expect. If success is false, we have an error field.

Now you’re ready to start using Typescript in your project! You should be able to turn these compiler type-checking options on quite easily, without a whole lot of pain fixing the issues found:

"allowUnreachableCode": false,
"allowUnusedLabels": false,
"alwaysStrict": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"strictBindCallApply": true,

I would advise that you turn this next set on as well, but I acknowledge that they might require more changes to your code. It’s worth it, though, because the things found are all potential bugs, either now or in the future. In the case of noImplicitAny, it means a lurking any, which means you’re pretty much just writing Javascript!

"noImplicitAny": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,

The rest of the type checking rules are, in my view, debatable:

// Almost an aesthetic decision - what does an optional field mean?
"exactOptionalPropertyTypes": true,
// Are you sure that you're always catching real Error objects? If not, turn this on
"useUnknownInCatchVariables": true,
// The definition changes over time!
"strict": true,

Alright, with that you’ve got the type-checking under control.

Now, how will you go from Typescript to the code you ship? You can tell Typescript to output your code to a dist/ location, which works fine for a node module or Node.js app. For this method, you could also try a cutting-edge compiler written in rust - it wouldn’t type-check, but you can easily do that with tsc --noEmit like we did above.

If you’re building via Webpack, you need to decide: do you want your Webpack build to fail with type-checking failures? Typescript can still generate workable code even if type-checking fails, so this may not be what you want since it slows down the build. You can turn off type checking in ts-loader, then again use tsc --noEmit for type-checking.

Finally, do you want to get even more strict and start using Typescript-aware eslint? Know that it can be very slow - you’ll want to use eslint’s caching feature. I think it’s worthwhile; it has a whole lot of useful rules. To fully take advantage of Typescript’s type-checking, I recommend that you enable at least three rules: @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types, and @typescript-eslint/no-non-null-assertion.

Don’t delay!

Start the move to Typescript sooner rather than later! You can start small, but you and your team will move faster once you have comprehensive types in place, allowing Typescript to give you good compile-time guardrails.

At the very least, write new features in Typescript, avoiding any or other type compromises. Otherwise, every new feature you write will take you further from the confidence that Typescript can provide!

I’d love to hear about your challenges in moving to Typescript - please reach out. Maybe I can help, and maybe I can help others in my next article like this!


Further exploration:

I won't share your email with anyone. See previous emails.

NEXT:

Take breaks (Dev productivity tip #6) 2021 Dec 12

I’ve been told that I’m a very productive developer. And it’s not magic; it’s a set of skills you can build! Welcome to the sixth in my developer productivity tips series: Take breaks. I’ve spoken... Read more »

PREVIOUS:

Don't write Javascript 2021 Nov 14

Yes, I’ve written a lot of Javascript. But it’s honestly an accident that we’re using it for such large, complex applications. It’s not a good language. We need to move on. Let’s talk about how you... Read more »


Hi, I'm Scott. I've written both server and client code in many languages for many employers and clients. I've also got a bit of an unusual perspective, since I've spent time in roles outside the pure 'software developer.' You can find me on Mastodon.