TypeScript Best Practices at Viget

Solomon Hawk, Senior Developer

Article Categories: #Code, #Front-end Engineering, #Back-end Engineering

Posted on

Lessons learned building with TypeScript.

At Viget, we’ve invested heavily in TypeScript. It’s been a great boon to our team’s ability to ship reliable, maintainable software. In time we’ve developed some opinions about how best to employ this technology for our clients.

This post assumes a familiarity with TypeScript. For an introduction to the language, see TypeScript: Documentation - Introduction.

Table of Contents #

  1. Configuration
  2. Type inference
  3. Features to avoid
  4. any vs unknown
  5. Fixed-length arrays
  6. const assertions
  7. Type assertions
  8. type aliases vs interfaces
  9. Access modifiers
  10. Linting & Formatting
  11. tsc vs babel

Configuration #

Use strict: true in tsconfig.json, and enable other strictness checks

Turning on strict: true will enable all of the following features which help TypeScript give us more assurance about the correctness of our code:

We also favor opting out of implicit returns and unused local bindings.

Though we don’t often bump into it, inconsistent casing in filenames /can/ be a source of cross-environment issues.

Babel automatically synthesizes default exports, so enabling the following allows TypeScript to behave similarly.

// without synthetic default imports:
import * as React from 'react'

// with synthetic default imports:
import React from 'react'

Type Inference #

Use type inference as much as possible

The TypeScript compiler is smart, we prefer to let it infer types for us. Inferred types are less explicit, but more flexible. This means less manual manipulation of types as the system evolves and types change. It’s less explicit though so things like IDE support are pretty crucial. Occasionally TypeScript can’t infer the correct type and we need to provide explicit types.

// WRONG
const items = [];   // incorrectly inferred as never[]
items.push(1);      // error: Type 'number' is not assignable to type 'never'.

// RIGHT
const items: number[] = [];
items.push(1);

Use typed type-programming and utility types for reflection, digging into, and composing types

It’s typical to work with types that are related to other types, whether those are part of our applications or from a third-party library. Because TypeScript uses structural sub-typing, we have a measure of flexibility in how we express the constraints of types in our code. Using composition and utility types to derive or create types that are interrelated gives us greater assurances and reduces maintenance overhead.

ts-essentials is a great library for expanding your arsenal of utility types.

// inferred as () => Promise<number>
function fetchWidgets() {
  return new Promise(resolve => resolve(1))
}

type FetchReturn = ReturnType<typeof fetchWidgets>; // Promise<number>
type FetchReturnValue = Awaited<FetchReturn>;       // number

Features to Avoid #

Avoid enums, use literal unions

TypeScript supports the enum keyword for creating enumerated types, but it breaks the contract that TypeScript is “JS with static type features added” due to generating additional runtime code. This is evident if you look at the tsc compiler output for this simple example.

// BAD
enum Scripts {
  JavaScript,
  TypeScript,
  PureScript,
  CoffeeScript
}

// GOOD
type Scripts = "JavaScript" | "TypeScript" | "PureScript" | "CoffeeScript"

Comparing the two approaches in practice, it’s true that updating the value of an enum member is simpler: just change the enum definition and you’re done (the rest of your code references the static constant enum value). In the case of literal unions you will need to change all the places that reference the value. In practice, however, the values of enum members rarely change after being added and the compiler will identify all the parts of the code that need to be updated.

Avoid namespaces, just use ES modules

The namespace keyword in TypeScript can be used to group related code, like modules, but more than 1 namespace can be declared inside a module. Stripping the TypeScript syntax/annotations yields invalid JS which means tsc has to emit some generated code to make them work which, similar to enums, breaks the contract.

The smart folks over at executeprogram.com wrote about this earlier this year.

any vs unknown #

Avoid any, use unknown when a type is ambiguous and use type-narrowing before dereferencing it

A few things to keep in mind:

  1. any makes tsc bail on type checking entirely, which tends to have a cascading effect on TypeScript's understanding of the code

  2. unknown is the type-safe equivalent

     let thing: unknown = 1; // ok
    
  3. Anything can be assigned to unknown, but unknown isn’t assignable to anything but itself and any without a type assertion or a control flow based narrowing:

     let n: number = thing; // type error, because we declared `thing` as `unknown`
    
  4. No operations are permitted on an unknown without first asserting or narrowing to a more specific type. Type Guards are a great way to narrow broad types like unknown safely:

     function isNumber(n: unknown): n is number {
       return typeof n === 'number';
     }
    

Fixed-length arrays #

Use fixed-length arrays, where appropriate

Declaring an array type can be done as a fixed or variable length declaration. Fixed-length arrays are useful for modeling “tuple” types - an ordered list type with a finite number of elements.

Variable-length

let items: number[] = [1, 2, 3]

items = []              // ok
items = [1]             // ok
items = [1, 2]          // ok
items = [1, 2, 3, 4, 5] // ok

Fixed-length

let items: [number, string] = [1, "ts"]

items = [4, "js"]    // ok
items = [1, "ps", 3] // error 
// Type '[number, string, number]' is not assignable to type '[number, string]'.
//	   Source has 3 element(s) but target allows only 2.

This is useful when if you have an array that has a specific “schema” with a fixed length.

const assertions #

Use const assertions, where appropriate

TypeScript type inference is quite smart, but it tends to infer the widest or most general type. Using const assertions causes TypeScript to infer the narrowest possible type. This is often useful for constant values, especially dictionaries or lists of known elements.

let items = [1, 2, 3];          // number[]
let items = [1, 2, 3] as const; // readonly [1, 2, 3]

A slightly more complex, practical example:

let computers = [
  { kind: "laptop", screenSize: 15 },
  { kind: "desktop", height: 24 },
] as const;

for (const computer of computers) {
  if (computer.kind === "laptop") {
    console.log("Laptop screen size", laptop.screenSize);
    /**
     * Without `as const` `computer` here is:
     *  {
     *      kind: string;
     *      screenSize: number;
     *      height?: undefined;
     *  } | {
     *      kind: string;
     *      height: number;
     *      screenSize?: undefined;
     *  }
     *
     * but with `as const`, `computer` is correctly narrowed to:
     *
     *  {
     *      readonly kind: "laptop";
     *      readonly screenSize: 15;
     *  }
     */
  } else {
    console.log("Desktop height", computer.height);
  }
}

Type Assertions #

Avoid type assertions, unless necessary

Type assertions let us tell TypeScript what the type of a certain thing is which is a lot like type casting (but not as a runtime effect). Using them is sort of an escape hatch that the compiler cannot statically verify. There are some rules around their usage that exist to prevent us from breaking known static contracts, but they can be abused when used incorrectly. Type assertions are important since there will be times where you as a developer know more about the type of a thing than TS can infer.

// HTMLElement
const myCanvas = document.getElementById("canvas") as HTMLCanvasElement;

Type assertions only allow narrowing and widening of types. tsc won’t let you declare a type assertion that’s incompatible with what it knows about a type.

const x = "hello" as number;
// error: Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.

There are cases where you might need to escape from this limitation. To do so you may do the following:

const thing = other as unknown as Thing;

This occasionally comes up when working with third-party libraries and tricky or impossible type inference. Look for opportunities to pass generic arguments first before reaching for this kind of pattern.

type aliases vs interfaces #

Use type aliases for React props/state, be consistent, utilize interfaces when authoring library public APIs or extending third-party types

  1. TypeScript has 2 ways of declaring “object-like” types, type and interface
  2. in most cases, they accomplish the same task
  3. there are some edge cases and limitations to be aware of that may lend certain use-cases to one or the other approach

We favor type aliases for most of our needs. They are explicit, concise, and aren’t susceptible to monkey-patching like interfaces are (due to declaration merging).

Note: these examples are adapted from Martin Hochell’s excellent post on Medium.

1. you cannot use implements on an class with type alias if you use union operator within your type definition

class Vehicle {
  tankCapacity: number;
  fuelEfficiency: number;
}

interface Traveller {
  range(): number;
}

type Fillable = {
  topupCost(perPerGallon: number): number
}

type SubaruVehicle = (Traveller | Fillable) & Vehicle;

// error: a class may only implement another class or interface
class Subaru implements SubaruVehicle {
  tankCapacity: 15;
  fuelEfficienty: 35;
  range() {
    return this.tankCapacity * this.fuelEfficiency;
  }
}

2. you cannot use extends on an interface with type alias if you use union operator within your type definition

type FillableOrTraveller = Fillable | Traveller;

// error: an interface may only extend a class or other interface
interface SubaruVehicle extends FillableOrTraveller, Vehicle {}

3. declaration merging doesn’t work with type alias

TypeScript will merge interface declarations with the same name (across all source files that tsc is aware of). This is incredibly useful for extending types provided by third-party libraries but should be avoided within your own app’s source code.

interface Box {
  height: number;
  width: number;
}

interface Box {
  scale: number;
}

const box: Box = { height: 2, width: 4, scale: 1 }; // ok

If you try this with a type alias, you’ll see “Duplicate declaration Box

Type aliases and interfaces when working with React props/state

// BAD
interface Props extends OwnProps, InjectedProps, StoreProps {}
type OwnProps = {...}
type StoreProps = {...}

// GOOD
type Props = OwnProps & InjectedProps & StoreProps
type OwnProps = {...}
type StoreProps = {...}

Access modifiers #

Use access modifiers for class properties

JavaScript hasn’t had real private and protected fields (until ES2022 which added the # prefix for private fields), but TypeScript can guard against violations of these constraints.

When working with classes it’s recommended to utilize these features to enforce access-related constraints within your software.

class Employee {
  protected name: string;
  private salary: number;

  constructor(name: string, salary: number) {
    this.name = name;
    this.salary = salary;
  }

  public getSalary() {
    return salary
  }
}

Linting & Formatting #

Use ESLint / Prettier with TypeScript

Using linters helps us maintain high code-quality standards, (we’re fans of making computers do work instead of humans, whenever possible). Auto-formatting code ensures consistency, saves time, and avoids bike-shedding.

Setting up ESLint and Prettier for TypeScript is mostly straightforward, with one caveat related to Prettier. Here’s an example repo with a barebones setup that implements this workflow.

1. Install the relevant packages

$ npm i -D eslint \
  prettier eslint-config-prettier eslint-plugin-prettier \
  typescript @typescript-eslint/eslint-plugin

2. Configure ESLint

// .eslintrc.js
module.exports = {
  parser: '@typescript-eslint/parser',
  extends: [
    'plugin:prettier/recommended',
    'plugin:@typescript-eslint/recommended',
  ],
};

3a. Run ESLint (manually), using the --fix flag to automatically apply fixes to source files

$ eslint --fix ./src/*{.ts,.tsx}

3b. Run ESLint (automatically) on save in VS Code

settings.json (VS Code)

{
  "eslint.format.enable": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true,
  }
}

4. Set up ESLint in a Continuous Integration environment

We often use GitHub Actions for CI due to its convenience when using GitHub for collaborating on git-based projects.

package.json

{
  ...,
  "scripts": {
    "lint": "eslint --fix ./src/*{.ts,.tsx}"
  }
  ...
}

.github/workflows/ci.yml

name: GitHub CI

on: [push]

jobs:
  lint-format:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-node@v3
      with:
        node-version: 16
        cache: true
    - run: npm ci
    - run: npm run lint

tsc vs babel #

If using babel, we make sure to actually type-check code

Both the TypeScript compiler (tsc) and babel can be used to transform TypeScript source code into JavaScript (with tsc if config.noEmit = false, which is the default, or with babel via the @babel/preset-typescript preset).

However, there are a few things to keep in mind:

  1. The babel transformation does not perform type-checking, so we must either run tsc with noEmit = true as part of the build and on CI or rely on IDE integration to check our types
  2. If working on a project that relies on babel compilation, it’s convenient to just add another preset and avoid the complication of 2 separate compilers emitting build artifacts
  3. For compiling TypeScript files with webpack, you can use either approach (either use ts-loader which uses tsc internally, or just add the preset to your babel configuration if using babel-loader and have it process .js and .ts files)

TypeScript React Cheatsheet #

There are a handful of common things you might want to do when building React applications using TypeScript. This reference is one you'll want to bookmark and come back to frequently.

In Closing #

TypeScript is a valuable tool that we continue to use regularly, invest in, and refine our opinions and best practices with. We hope our experience and the lessons we have learned can help you avoid common pitfalls and understand the trade-offs of different approaches to using TypeScript on your projects.

Solomon Hawk

Solomon is a developer in our Durham, NC, office. He focuses on JavaScript development for clients such as ID.me. He loves distilling complex challenges into simple, elegant solutions.

More articles by Solomon

Related Articles