Effective TypeScript

Table of Contents

author
Dan Vanderkam

1. Effective Typescript

1.1. Preface

Whereas a reference book will explain the five ways that a language lets you do X, an Effective book will tell you which of those five to use and why.

1.2. Getting to Know TypeScript

1.2.1. Item 1: Understand the Relationship Between TypeScript and JavaScript

TypeScript is a superset of JavaScript.

1.2.2. Item 2: Know Which TypeScript Options You're Using

1.2.3. Item 3: Understand That Code Generation Is Independent of Types

1.2.3.1. Code with Type Errors Can Produce Output
1.2.3.2. You Cannot Check TypeScript Types at Runtime
interface Square {
  width: number
}
interface Rectangle extends Square {
  height: number
}
type Shape = Square | Rectangle
function calculateArea(shape: Shape) {
  if ('height' in shape) {
    shape; // Type is Rectangle
    return shape.width * shape.height
  } else {
    shape; // Type is Square
    return shape.width * shape.width
  }
}

Another way would have been to introduct a "tag" to explicitly store the type in a way that's available at runtime:

interface Square {
  kind: 'square'
  width: number
}
interface Rectangle {
  kind: 'rectangle'
  height: number
  width: number
}
type Shape = Square | Rectangle
function calculateArea(shape: Shpae) {
  if (shape.kind === 'rectangle') {
    shape // Type is Rectangle
    return shape.width * shape.height
  } else {
    shape // Type is Square
    return shape.width * shape.width
  }
}
1.2.3.3. You Cannot Overload a Function Based on TypeScript Types

You can provide multiple declarations for a function, but only a single implementation:

function add(a: number, b: number): number
function add(a: string, b: string): string
function add(a, b) {
  return a + b
}
const three = add(1, 2) // Type is number
const twelve = add('1', '2') // Type is string
1.2.3.4. Things to Remember
  • Code generation is independent of the type system. This means that TypeScript types cannot affect the runtime behavior or performance of your code.
  • It is possible for a program with type errors to produce code.
  • TypeScript types are not available at runtime. To query a type at runtime, you need some way to reconstruct it. Tagged unions and property checking are common ways to do this. Some constructs, such as class, introduce both a TypeScript type and a value that is available at runtime.

1.2.4. Item 4: Get Comfortable with Structural Typing

  • Understand that JavaScript is duck typed and TypeScript uses structural typing to model this: values assignable to your interfaces might have properties beyond those explicitly listed in your type definitions. Types are not "sealed".
  • Be aware that classes also follow structural typing rules. You may not have an instance of the class you expect!
  • Use structural typing to facilitate unit testing.

1.2.5. Item 5: Limit Use of the any Type

1.3. TypeScript's Type System

1.3.1. Item 6: Use Your Editor to Interrogate and Explore the Type System

1.3.2. Item 7: Think of Types as Sets of Values

  • Think of types as sets of values (the type's domain). These sets can either be finite (e.g., boolean or literal types) or infinite (e.g., number or string).
  • TypeScript types form intersecting sets rather than a strict hierarchy. Two types can overlap without either being a subtype of the other.
  • Remember that an objet can still belong to a type even if it has additional properties that were not mentioned in the type declaration.
  • Type operations apply to a set's domain. The intersection of A and B is the intersection of A's domain and B's domain. For object types, this means that values in A & B have the properties of both A and B.
  • Think of "extends", "assignable to", and "subtype of" as synonyms for "sybset of".

1.3.3. Item 8: Know How to Tell Whether a Symbol Is in the Type Space or Value Space

  • Every value has a type, but types do not have values. Constructs such as type and interface exist only in the type space.
  • typeof, this and many other operators and keywords have different meanings in type space and value space.
  • Some constructs such as class or enum introduce both a type and a value.

1.3.4. Item 9: Prefer Type Declarations to Type Assertions

  • Prefer type declarations(: Type) to type assertions(as Type).
  • Use type assertions and non-null assertions when you know something about types that TypeScript does not.

1.3.5. Item 10: Avoid Object Wrapper Types(String, Number, Boolean, Symbol, BigInt)

1.3.6. Item 11: Recognize the Limits of Excess Property Checking

index signature

interface Options {
  darkMode?: boolean
  [otherOptions: string]: unknown
}

When you assign an object literal to a variable or pass it as an argument to a function, it undergoes excess property checking.

1.3.7. Item 12: Apply Types to Entire Function Expressions When Possible

  • Consider applying type annotations to entire function expressions, ranther than to their parameters and return type.
  • If you're writing the same type signature repeatedly, factor out a function type or look for an existing one. If you're a library author, provider types for common callbacks.
  • Use typeof fn to match the signature of another function.

1.3.8. Item 13: Know the Differences Between type and interface

  • Understand the differences and similarities between type and interface.
  • Know how to write the same types using either syntax.
  • In deciding which to use in your project, consider the established style and whether augmentation might be beneficial.

1.3.9. Item 14: Use Type Operations and Generics to Avoid Repeating Yourself

type State = {
  userId: string
  pageTitle: string
  recentFiles: string[]
  pageContnets: string
}
type TopNavState = {
  [k in 'userId' | 'pageTitle' | 'recentFiles']: State[k]
}

Pick's declaration in the standard library:

type Pick<T, K> = { [k in K]: T[k] }

Similar:

type TopNavState = Pick<State, 'userId' | 'pageTitle' | 'recentFiles'>
  • The DRY (don't repeat yourself) principle applies to types as much as it applies to logic.
  • Name types rather than repeating them. Use extends to avoid repeating fields in interfaces.
  • Build an understanding of the tools provided by TypeScript to map between types. These include keyof, typeof, indexing, and mapped types.
  • Generic types are the equivalent of functions for types. Use them to map between types instead of repeating types. Use extends to constrain generic types.
  • Familiarize yourself with generic types defined in the standard library such as Pick, Partial, and ReturnType.

1.3.10. Item 15: Use Index Signatures for Dynamic Data

type Vec3D = Record<'x' | 'y' | 'z', number>
// Type Vec3D = {
//  x: number
//  y: number
//  z: number
//}
type V3D = { [k in 'x' | 'y' | 'z']: number } // same as above

type ABC = { [k in 'a' | 'b' | 'c']: k extends 'b' ? string : number }
// Type ABC = {
//  a: number
//  b: string
//  c: number
//}
  • Use index signatures when the properties of an object cannot be known until runtime–for example, if you're loading them from a CSV file.
  • Consider adding undefined to the value type of an index signature for safer access.
  • Prefer more precise types to index signatures when possible: interfaces, Records, or mapped types.

1.3.11. Item 16: Prefer Arrays, Tuples, and ArrayLike to number Index Signatures

1.3.12. Item 17: Use readonly to Avoid Errors Associated with Mutation

  • If your function does not modify its parameters then declare them readonly. This makes its contract clearer and prevents inadvertent mutations in its implementation.
  • Use readonly to prevent errors with mutation and to find the palces in your code where mutations occur.
  • Understand the defference between const and readonly.
  • Understand that readonly is shallow.

1.3.13. Item 18: Use Mapped Types to Keep Values in Sync

const REQUIRES_UPDATE: { [k in keyof ScatterProps]: boolean } = {
  xs: true,
  ys: true,
  // ...
}

const PROPS_REQUIRING_UPDATE: (keyof ScatterProps)[] = [
  'xs',
  'ys',
  // ...
]

Mapped types are ideal if you want one object to have exactly the same properties as another.

  • Use mapped types to keep related values and types synchronized.
  • Consider using mapped types to force choices when adding new properties to an interface.

1.4. Type Inference

1.4.1. Item 19: Avoid Cluttering Your Code with Inferable Types

  • Avoid writing type annotations when TypeScript can infer the same type.
  • Ideally your code has type annotations in function/method signatures but not on local variables in their bodies.
  • Consider using explicit annotations for object literals and function return types even when they can be inferred. This will help prevent implementation errors from surfacing in user code.

1.4.2. Item 20: Use Different Variables for Different Types

1.4.3. Item 21: Understand Type Widening

1.4.4. Item 22: Understand Type Narrowing

function isInputElement(el: HTMLElement): el is HTMLInputElement {
  return 'value' in el
}

function isDefined<T>(x: T | undefined): x is T {
  return x !== undefined
}

The el is HTMLInputElement as a return type tells the type checker that it can narrow the type of the parameter if the function returns true.

  • Understand how TypeScript narrows types based on conditionals and other types of control flow.
  • Use tagged/discriminated unions and user-defined type guards to help the process of narrowing.

1.4.5. Item 23: Create Objects All at Once

declare let hasMiddle: boolean
const firstLast = { first: 'Harry', last: 'Truman' }
const president = { ...firstLast, ...(hasMiddle ? { middle: 'S' } : {}) }
function addOptional<T extends object, U extends object>(
  a: T, b: U | null
): T & Partial<U> {
  return { ...a, ...b }
}
declare let hasMiddle: boolean
const firstLast = { first: 'Harry', last: 'Truman' }
const president = addOptional(firstLast, hasMiddle ? { middle: 'S' } : null)
  • Prefer to build objects all at once rather than piecemeal. Use object spread ({ ...a, ...b }) to add propertties in a type-safe way.
  • Know how to conditionally add properties to an object.

1.4.6. Item 24: Be Consistent in Your Use of Aliases

  • Aliasing can prevent TypeScript from narrowing types. If you create an alias for a variable, use it consistently.
  • Be aware of how function calls can invalidate type refinements on properties. Trust refinements on local variables more than on properties.

1.4.7. Item 25: Use async Functions Instead of Callbacks for Asynchronous Code

async function fetchAll() {
  const [users, posts] = await Promise.all([fetchUsers(), fetchPosts()])
}

function timeout(ms: number): Promise<never> {
  return new Promise((_resolve, reject) => {
    setTimeout(() => reject('timeout'), ms)
  })
}

async function fetchWithTimeout(url: string, ms: number) {
  return Promise.race([fetch(url), timeout(ms)])
}
  • Prefer async and await to row Promises when possible. They produce more concise, straightforward code and eliminate whole classes of errors.
  • If a function returns a Promise, declare it async.

1.4.8. Item 26: Understand How Context Is Used in Type Inference

1.4.9. Item 27: Use Functional Constructs and Libraries to Help Types Flow

  • Use built-in functional constructs and those in utility libraries like Lodash instead of hand-rolled constructs to improve type flow, increase legibility, and reduce the need for explicit type annotations.

1.5. Type Design

1.5.1. Item 28: Prefer Types That Always Represent Valid States

  • Types that represent both valid and invalid states are likely to lead to confusing and error-prone code.
  • Prefer types that only represent valid states. Even if they are longer or harder to express, they will save you time and pain in the end.

1.5.2. Item 29: Be Liberal in What You Accept and Strict in What You Produce

type LngLat = { lng: number, lat: number }
type LngLatLike = LngLat | { lon: number, lat: number } | [number, number]

type Camera = {
  center: LngLat,
  zoom: number,
  bearing: number,
  pitch: number
}

type CameraOptions = Omit<Partial<Camera>, 'center'>  & {
  center?: LngLatLike
}
type LngLatBounds =
  { northeast: LngLatLike, southwest: LngLatLike} |
  [LngLatLike, LngLatLike] |
  [number, number, number, number]

declare function setCamera(camera: CameraOptions): void
declare function viewportForBounds(bounds: LngLatBounds): Camera
  • Input types tend to be broader than output types. Optional properties and union types are more common in parameter types than return types.
  • To reuse types between parameters and return types, introduce a canonical form (for return types) and a looser form (for parameters).

1.5.3. Item 30: Don't Repeat Type Information in Documentation

  • Consider including units in variable names if they aren't clear from the type (e.g., timeMS or temperatureC).

1.5.4. Item 31: Push Null Values to the Perimeter of Your Types

function extent(nums: number[]) {
  let result: [number, number] | null = null
  for (const num in nums) {
    if (!result) {
      result = [num, num]
    } else {
      result = [Math.min(num, result[0]), Math.max(num, result[1])]
    }
  }
  return result
}

const range = extent([0, 1, 2])
if (range) {
  const [min, max] = range
  //...
}
const [min, max] = range!
class UserPosts {
  user: UserInfo
  posts: Post[]

  constructor(user: UserInfo, posts: Post[]) {
    this.user = user
    this.posts = posts
  }

  static async init(userId: string): Promise<UserPosts> {
    const [user, posts] = await Promise.all([
      fetchUser(userId),
      fetchPostsForUser(userId)
    ])
    return new UserPosts(user, posts)
  }

  getUserName() {
    return this.user.name
  }
}
  • Avoid designs in which one value being null or not null is implicitly related to another value being null or not null.
  • Push null values to the perimeter of your API by making larger objects either null or fully non-null. This will make code clearer both for human readers and for the type checker.
  • Consider creating a fully non-null class and constructing it when all values are available.
  • While strictNullChecks may flag many issues in your code, it's indispensable for surfacing the behavior of functions with respet to null values.

1.5.5. Item 32: Prefer Unions of Interfaces to Interfaces of Unions

  • Interfaces with multiple properties that are union types are often a mistake because they obscure the relationships between these properties.
  • Unions of interfaces are more precise and can be understood by TypeScript.
  • Consider adding a "tag" to your structure to facilitate TypeScript's control flow analysis. Because they are so well supported, tagged unions are ubiquitous in TypeScript code.

1.5.6. Item 33: Prefer More Precise Alternatives to String Types

function pluck<T, K extends keyof T>(records: T[], key: K): T[K][] {
  return records.map(record => record[key])
}
  • Avoid "stringly typed" code. Prefer more appropriate types where not every string is a possibility.
  • Prefer a union of string literal types to string if that more accurately describes tE domain of a variable. You'll get stricter type checking and improve the development experience.
  • Prefer keyof T to string for function parameters that are expected to be properties of an object.

1.5.7. Item 34: Prefer Incomplete Types to Inaccurate Types

  • Avoid the uncanny valley of type safety: incorrect types are often worse than no types.
  • If you cannot model a type accurately, do not model it inaccurately! Acknowledge the gaps using any or unknown.
  • Pay attentation to error messages and autocomplete as you make typings increasingly precise. It's not just about correctness: developer experience matters, too.

1.5.8. Item 35: Generate Types from APIs and Specs, Not Data

  • Consider generating types for API calls and data formats to get type safety all the way to the edge of your code.
  • Prefer generating code from specs rather than data. Rare cases matter!

1.5.9. Item 36: Name Types Using the Language of Your Problem Domain

  • Reuse names from the domain of your problem where possible to increase the readability and level of abstraction of your code.
  • Avoid using different names for the same thing: make distinctions in names meaningful.

1.5.10. Item 37: Consider "Brands" for Nominal Typing

type AbsolutePath = string & { _brand: 'abs' }

function isAbsolutePath(path: string): path is AbsolutePath {
  return path.startsWith('/')
}
type SortedList<T> = T[] & { _brand: 'sorted' }
type Meters = number & { _brand: 'meters' }
type Seconds = number & { _brand: 'seconds' }

const meters = (m: number) => m as Meters
const seconds = (s: number) => s as Seconds
  • TypeScript uses structural ("duck") typing, which can sometimes lead to surprising results. If you need nominal typing, consider attaching "brands" to your values to distinguish them.
  • In some cases you may be able to attach brands entirely in the type system, rather than at runtime. You can use this technique to model properties outside of TypeScript's type system.

1.6. Working with any

1.6.1. Item 38: Use the Narrowest Possible Scope for any Types

1.6.2. Item 39: Prefer More Precise Variants of any to Plain any

  • When you use any, think about whether any JavaScript value is truly permissible.
  • Prefer more precise forms of any such as any[] or {[id: string]: any} if they more accurately model your data.

1.6.3. Item 40: Hide Unsafe Type Assertions in Well-Typed Functions

Sometimes unsafe type assertions are necessary or expedient. When you need to use one, hide it inside a function with a correct signature.

1.6.4. Item 41: Understand Evolving any

  • While TypeScript types typically only refine, implicit any and any[] types are allowed to envolve. You should be able to recognize and understand this construct where it occurs.
  • For better error checking, consider providing an explicit type annotation instead of using evolving any.

1.6.5. Item 42: Use unknown Instead of any for Values with an Unknown Type

The power and danger of any come from two properties:

  • Any type is assignable to the any type.
  • The any type is assignable to any other type.

The type checker is set-based, the use of any effectively disables it.

unknown:

  • Any type is assignable to the unknown type.
  • The unknown type is only assignable to unknown and any.

never:

  • Nothing can be assigned to never.
  • The never type can be assigned to any other type.

1.6.6. Item 43: Prefer Type-Safe Approaches to Monkey Patching

export {}
declare global {
  interface Document {
    monkey: string;
  }
}
document.monkey = 'Tamarin'

interface MonkeyDocument extends Document {
  monkey: string
}

(document as MonkeyDocument).monkey = 'Tamarin'
interface FooWindow extends Window {
  foo: string
}

function isFooWindow(window: Window): window is FooWindow {
  return 'foo' in window
}

function foo(window: Window) {
  if (isFooWindow(window)) {
    window.foo
  }
}
  • Prefer structured code to storing data in globals or on the DOM.
  • If you must store data on built-in types, use one of the type-safe approaches (augmentation or asserting a custom interface).
  • Understand the scoping issues of augmentations.

1.6.7. Item 44: Track Your Type Coverage to Prevent Regressions in Type Safety

type-coverage --detail

  • Even with noImplicitAny set, any types acan make their way into your code either through explicit anys or third-party type declarations.
  • Consider tracking how well-typed your program is. This will encourage you to revisit decisions about using any and increase type safety over time.

1.7. Types Declarations and @types

1.7.1. Item 45: Put TypeScript and @types in devDependencies

1.7.2. Item 46: Understand the Three Versions Involved in Type Declarations

  • There are three versions involved in an @types dependency: the library version, the @types version, and the TypeScript version.
  • If you update a library, make sure you update the corresponding @types.

1.7.3. Item 47: Export All Types That Appear in Public APIs

1.7.4. Item 48: Use TSDoc for API Comments

1.7.5. Item 49: Provide a Type for this in Callbacks

function addKeyListener(
  el: HTMLElement,
  fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
  el.addEventListener('keydown', e => {
    fn.call(el, e)
  })
}

declare let el: HTMLElement
addKeyListener(el, function(e) {
  this.innerHTML  // "this" has type fo HTMLElement
})

1.7.6. item 50: Prefer Conditional Types to Overloaded Declarations

function double<T extends number | string>(x: T): T
function double(x: any) {
  return x + x
}

const a = double(1) // type of a is '1', not number
const b = double('b') // type of b is 'b', not string
function double<T extends number | string>(x: T): T extends number ? number : string
function double(x: any) {
  return x + x;
}

const a = double(1)   // number
const b = double('b') // string

Prefer conditional types to overloaded type declarations. By distributing over unions, conditional types allow your declarations to support union types without additional overloads.

1.7.7. Item 51: Mirror Types to Sever Dependencies

interface CsvBuffer {
  toString(encoding: string): string
}

// We just need Buffer.toString here.
function parseCSV(contents: string | CsvBuffer): { [column: string]: string }[] {
  if (typeof contents === 'object') {
    // It's a Buffer
    return parseCSV(contents.toString('utf8'))
  }
  // ...
  return []
}

// Buffer is Node.js's Buffer type
parseCSV(new Buffer("column1,column2\nvalue1,value2", "utf-8"))
  • Use structural typing to sever dependencies that are nonessential.
  • Don't force JavaScript users to depend on @types. Don't force web developers to depend on NodeJS.

1.7.8. Item 52: Be Aware of the Pitfalls of Testing Types

function assertType<T>(value: T): T {
  return value
}

const a = 1
assertType<1>(a)      // ok
assertType<number>(a) // ok. a's type is 1, it's a subtype of number
assertType<string>(a) // a's type is 1, it's not a subtype of string

const double = (x: number) => x * 2
let p: Parameters<typeof double> = null!
assertType<[number]>(p)         // ok
assertType<[number, number]>(p) // [number] is not assignable to [number, number]
let r: ReturnType<typeof double> = null!
assertType<number>(r) // ok


declare function map<U, V>(
  array: U[],
  fn: (this: U[], item: U, index: number, array: U[]) => V
): V[]

const beatles = ['John', 'Paul', 'George', 'Ringo']
assertType<number[]>(map(
  beatles,
  function(name, i, arr) {
    assertType<string>(name)   // ok
    assertType<number>(i)      // ok
    assertType<string[]>(arr)  // ok
    assertType<string[]>(this) // ok
    return name.length
  }
))
  • When testing types, be aware of the difference between equality and assignability, particularly for function types.
  • For functions that use callbacks, test the inferred types of the callback parameters. Don't forget to test the type of this if it's part of your API.
  • Be aware of any in tests involving types. Consider using a tool like dtslint for stricter, less error-prone checking.

1.8. Writing and Running Your Code

1.8.1. Item 53: Prefer ECMAScript Features to TypeScript Features

  • Enums, parameter properties, triple-slash imports, and decorators are historical exceptions to this rule.
  • In order to keep TypeScript's role in your codebase as clear as possible, I recommend avoiding these features.

1.8.2. Item 54: Know How to Iterate Over Objects

  • Use let k: keyof T and a for-in loop to iterate objects when you know exactly what the keys will be. Be aware that any objects your function receives as parameters might have additional keys.
  • Use Object.entries to iterate over the keys and values of any object.

1.8.3. Item 55: Understand the DOM Hierarchy

  • Know The Differences Between Node, Element, HTMLElement, And EventTarget, As Well As Those Between Event And MouseEvent.
  • Either Use A Specific Enough Type For DOM Elements And Events In Your Code Or Give TypeScript The Context To Infer It.

1.8.4. Item 56: Don't Rely On Private To Hide Information

  • The Private Access Modifier Is Only Enforced Through The Type System. It Has No Effect At Runtime And Can Be Bypassed With An Assertion. Don't Assume It Will Keep Data Hidden.
  • For More Reliable Information Hiding, Use A Closure.

1.8.5. Item 57: Use Source Maps To Debug Typescript

1.9. Migrating to TypeScript

1.9.1. Item 58: Write Modern JavaScript

1.9.2. Item 59: Use @ts-check and JSDoc to Experiment with TypeScript

1.9.3. Item 60: Use allowJs to Mix TypeScript and JavaScript

1.9.4. Item 61: Convert Module by Module Up Your Dependency Graph

  • Start migration by adding @types for third-party modules and external API calls.
  • Begin migrating your modules from the bottom of the dependency graph upwards. The first module will usually be some sort of utility code. Consider visualizing the dependency graph (madge tool) to help you track progress.
  • Resist the urge to refactor your code as you uncover odd designs. Keep a list of ideas for future refactors, but stay focused on TypeScript conversion.
  • Be aware of common errors that come up during conversion. Copy JSDoc annotations if necessary to avoid losing type safety as you convert.

1.9.5. Item 62: Don't Consider Migration Complete Until You Enable noImplicitAny