Kyle Nazario

How to incrementally migrate an Angular project to TypeScript strict mode

How to incrementally migrate an Angular project to TypeScript strict mode

Enabling strict mode for TypeScript is one of the best ways to ensure code quality on a project. It forces developers to handle edge cases and avoid risky type coercions. It also exposes hidden bugs.

However, it is daunting to add "strict": true to your tsconfig.json and see pages of build errors. As a developer, you never want to have to tell a product manager, “Sorry, new features are paused this week.”

Experienced developers also know to avoid big rewrites that take weeks to get out the door. The longer your strict mode migration goes, the more likely it is to have a blocking bug or cause massive merge conflicts or just fail. It’s better to consistently ship small, well-tested increments of code.

And make no mistake, there will be build errors. Consider this example based on real code I’ve encountered before:

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'pmo-menu',
  template: ` <button (click)="addDish()">Add Dish</button> `,
  styleUrls: ['./menu.component.less']
})
export class MenuComponent implements OnInit {
  currentUser: User;

  constructor(
    private dishService: DishService,
    private userService: UserService
  ) {}

  ngOnInit() {
    this.userService.currentUser$.subscribe(
      currentUser => (this.currentUser = currentUser)
    );

    // fails because this.currentUser is undefined
    console.log('currentUser:', this.currentUser.id);
  }

  addDish() {
    this.dishService.addDish(this.currentUser.id);
  }
}

TypeScript throws a build error because this.currentUser is never assigned a value in the constructor or at declaration. This is correct! Until the currentUser$.subscribe() callback runs, this.currentUser is undefined. Its type should be User | undefined. This lets other developers who edit this component know they can’t always rely on this.currentUser existing.

Strict mode is great for catching errors like this. With third-party help and planning, you can adopt it.

Background

As of TypeScript 4.7.3, the strict flag is shorthand for these compiler flags:

  • noImplicitAny

  • noImplicitThis

  • alwaysStrict

  • strictBindCallApply

  • strictNullChecks

  • strictFunctionTypes

  • strictPropertyInitialization

  • useUnknownInCatchVariables

noImplicitAny

Throws an error if the automatic type inference ever infers the type is any.

// TS7006: Parameter 'dishId' implicitly has an 'any' type.
addDish(dishId) {
  this.dishService.addDish(dishId);
}

noImplicitThis

Throws an error if the automatic type inference ever infers the type of this in a block of code is any.

getAddDishCallback() {
  return function(dishId: number) {
    // TS2683: 'this' implicitly has type 'any' because it does not have a type annotation.
    this.dishService.addDish(dishId);
  }
}

alwaysStrict

Parses every TypeScript file using ES5 strict JavaScript parsing, which throws errors when trying to do something inadvisable. When not using ES5 strict parsing, these operations fail silently. As explained by MDN:

'use strict';

// Assignment to a non-writable global
var undefined = 5; // throws a TypeError
var Infinity = 5; // throws a TypeError

// Assignment to a non-writable property
var obj1 = {};
Object.defineProperty(obj1, 'x', { value: 42, writable: false });
obj1.x = 9; // throws a TypeError

// Assignment to a getter-only property
var obj2 = {
  get x() {
    return 17;
  }
};
obj2.x = 5; // throws a TypeError

// Assignment to a new property on a non-extensible object
var fixed = {};
Object.preventExtensions(fixed);
fixed.newProp = 'ohai'; // throws a TypeError

strictBindCallApply

Requires correct argument types when using bind(), call() and apply().

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'pmo-menu',
  template: ``,
  styleUrls: ['./menu.component.less']
})
export class MenuComponent implements OnInit {
  currentUser: User | undefined;

  constructor(
    private dishService: DishService,
    private userService: UserService
  ) {}

  ngOnInit() {
    this.userService.currentUser$.subscribe(
      currentUser => (this.currentUser = currentUser)
    );
  }

  addDish(dishId: number) {
    this.dishService.addDish(dishId);
  }
}

strictNullChecks

If an variable’s type is T | undefined, TypeScript throws an error if you treat it as just T. It also treats null and undefined as separate values.

addDish(dishId: number) {
  const existingDish = this.dishService.dishes.find(dish => dish.id === dishId);
  // object is possibly undefined
  this.dishService.addDish(existingDish.id);
}

strictFunctionTypes

Requires function parameters and returns to be compatible to treat two functions as the same type.

export class MenuComponent implements OnInit {
  currentUser: User | undefined;

  getUser: (name: string) => User;

  constructor(
    private dishService: DishService,
    private userService: UserService
  ) {}

  ngOnInit() {
    this.getCurrentUser = this.userService.getCurrentUser;
  }
}
/**
Type '(id: number) => User' is not assignable to type '(name: string) => User'.
  Types of parameters 'id' and 'name' are incompatible.
    Type 'number' is not assignable to type 'string'.
*/

strictPropertyInitialization

If a property is not T | undefined, it must be assigned a value of type T in the constructor or when it is declared.

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'pmo-menu',
  template: ` Add Dish `,
  styleUrls: ['./menu.component.less']
})
export class MenuComponent implements OnInit {
  // TS2564: Property 'currentUser' has no initializer and is not definitely assigned in the constructor.
  currentUser: User;

  constructor(
    private dishService: DishService,
    private userService: UserService
  ) {}

  ngOnInit() {
    this.userService.currentUser$.subscribe(
      currentUser => (this.currentUser = currentUser)
    );

    console.log('currentUser:', this.currentUser.id);
  }

  addDish() {
    this.dishService.addDish(this.currentUser.id);
  }
}

useUnknownInCatchVariables

Types the err variable in catch() blocks as unknown, not automatically Error. Technically you could throw anything in a try block.

async addDish(dishId: number) {
  try {
    this.dishService.addDish(existingDish.id);
  } catch (e) {
    if (e instanceof Error) {
      console.error(e.message);
    }
  }
}

Options for incrementally adopting strict mode

Don’t: Multiple tsconfig files

One piece of advice I see a lot online is to use multiple tsconfig.json files. This is inadvisable because you will have to run tsc once for each tsconfig file. There are other, easier options.

Do: Enable individual flags

As explained above, "strict": true is shorthand for several properties. One way to incrementally adopt strict mode is to run builds with individual strict properties enabled and see how many errors each flag generates. If a flag causes little to no build errors, these can be enabled immediately.

Over time, your team can enable each strict sub-flag. When all of them are active, you can replace them with "strict": true.

This approach gives your code some of the benefits of strict mode immediately. However, some sub-flags of strict mode are disproportionately difficult to enable. Even if you get noImplicitThis for free, strictNullChecks might require a large amount of work.

Do: Use typescript-strict-plugin

typescript-strict-plugin is an NPM package that allows you apply strict mode to either:

  • All files, with some exempted using // @ts-strict-ignore
  • Any directory or file specified in your tsconfig.json

This plugin really breaks up the work of strict mode. You could, for example, incrementally add directories to be parsed strictly. You could also require strict mode for all code except the files at time of setup, so all new code is strict.

The biggest downside to this approach is it adds complexity to your build process by adding a third-party plugin.

Do: Use ts-strictify

ts-strictify requires developers to implement strict mode in any file they edit. It can be added as a pre-commit hook using husky or lefthook.

This package is a good way to require developers edit code moving forward, as opposed to relying on the product manager to prioritize stories to clean up old code. However, it sounds daunting to implement strict mode in giant old files.

Final recommendation

The best way to adopt strict mode depends on your organization, team makeup, and story selection process. However, I would recommend a mix of three approaches:

  • If a strict mode sub-flag like strictBindCallApply generates so few errors you could fix them in a day, enable it immediately. Do this for all the “easy” flags.
  • Use typescript-strict-plugin to exempt all existing code from strict mode, enable it for new code and periodically update the old code to be strict-compliant. That way you’re not adding to the pile of strict mode updates.