BattlefyBlogHistoryOpen menu
Close menuHistory

Refactor lying TypeScript type assertions into real assertions

Ronald ChenJuly 11th 2022

TypeScript is powerful, but was retrofitted upon JavaScript and the DOM API. Some APIs work pooly with TypeScript as the type information is missing. A common issue is document.getElementById can return any HTML element.

The problem

document.body.insertAdjacentHTML('beforeend', '<canvas id="main-content">');

const canvas = document.getElementById('main-content');
// TypeScript can only assume canvas is a HTMLElement

const ctx = canvas.getContext('2d');
// TypeScript error
//   Object is possibly 'null'.
//   Property 'getContext' does not exist on type 'HTMLElement'.

Playground

Type assertion

The TypeScript as operator is a Type assertion. It is a form of Type conversion and tells TypeScript, "I know what I'm doing, this thing is really that type".

document.body.insertAdjacentHTML('beforeend', '<canvas id="main-content">');

const canvas = document.getElementById('main-content') as HTMLCanvasElement;
// TypeScript has been told canvas is HTMLCanvasElement

const ctx = canvas.getContext('2d');

Playground

Lying type assertions

However, note type assertions tell TypeScript, "I know what I'm doing". What if that is no longer true? What if we decide main-content is no longer a <canvas> but now an <div>? The code would fail, but it would fail at the time in which getContext is called. What if getContext is in another module?

document.body.insertAdjacentHTML('beforeend', '<div id="main-content">');

const canvas = document.getElementById('main-content') as HTMLCanvasElement;
// TypeScript has been lied to that canvas is HTMLCanvasElement

const ctx = canvas.getContext('2d');
// Runtime error
//   canvas.getContext is not a function

Playground

That error message is confusing. It would be nice if the error was closer to the source of the issue.

Assertion functions to the rescue

The issue is type assertions exist only at compile time to tell TypeScript information. There is no running JavaScript code that actually checks the type at runtime. This is where Assertion functions come in. Assertion functions assert types using actual running code.

function assertInstanceOf<T>(value: any, expectedClass: new () => T): asserts value is T {
  if (!(value instanceof expectedClass)) {
    throw new Error(`Expected value to be a ${expectedClass.name}, but was ${value.constructor.name}`);
  }
}

document.body.insertAdjacentHTML('beforeend', '<canvas id="main-content">');

const canvas = document.getElementById('main-content');

assertInstanceOf(canvas, HTMLCanvasElement);
// TypeScript now knows canvas is a HTMLCanvasElement

const ctx = canvas.getContext('2d');

Playground

The strange type new () => T simply means "a class of type T". The magic part is asserts value is T. This is a promise to TypeScript that, "This function will assert value is type T and will throw an error otherwise"

Now when main-content becomes a <div>, an error is thrown.

function assertInstanceOf<T>(value: any, expectedClass: new () => T): asserts value is T {
  if (!(value instanceof expectedClass)) {
    throw new Error(`Expected value to be a ${expectedClass.name}, but was ${value.constructor.name}`);
  }
}

document.body.insertAdjacentHTML('beforeend', '<div id="main-content">');

const canvas = document.getElementById('main-content');

assertInstanceOf(canvas, HTMLCanvasElement);
// Runtime error
//   Expected value to be a HTMLCanvasElement, but was HTMLDivElement 

const ctx = canvas.getContext('2d');

Playground

Excellent! The error is now close to where canvas is first declared making it far easier to fix.

Do you want to write honest TypeScript? You're in luck, Battlefy is hiring.

2022

Powered by
BATTLEFY