Preventing "illegal invocation" errors in TypeScript

Published on in TypeScript

The TypeScript compiler could help prevent "illegal invocation" errors at compile time, at least theoretically.

Table of contents

Illegal invocation errors?

Chromium browsers throw an "illegal invocation" error when calling a function whose this keyword isn't referring to the object where it originally did, i.e. when the "context" of the function is lost.

Other browsers and Node.js produce different error messages.

Check out my earlier blog post for more details: "Illegal invocation" errors in JavaScript.

TypeScript is of no help by default

By default, TypeScript doesn't balk at this code, resulting in runtime errors when calling the functions:

const abortController = new AbortController()
const { abort } = abortController

abort() //=> TypeError: Illegal invocation

const $ = document.querySelector
const $$ = document.querySelectorAll

$('#foo') //=> TypeError: Illegal invocation
$$('.bar') //=> TypeError: Illegal invocation

Declaring the type of this

TypeScript could spot the errors at compile time because you can declare the type of a function's this keyword in TypeScript.

For example, the default Document interface specifies the querySelector method like this:

interface Document {
  querySelector<K extends keyof HTMLElementTagNameMap>(
    selectors: K
  ): HTMLElementTagNameMap[K] | null
  querySelector<K extends keyof SVGElementTagNameMap>(
    selectors: K
  ): SVGElementTagNameMap[K] | null
  querySelector<E extends Element = Element>(
    selectors: string
  ): E | null

  // + many other methods and properties
}

(The querySelector method is specified three times on purpose; this is called function overloading.)

If the interface also specified the type of the querySelector method's this keyword, the TypeScript compiler wouldn't let you call the method illegally:

interface Document {
  querySelector<K extends keyof HTMLElementTagNameMap>(
    this: Document,
    selectors: K
  ): HTMLElementTagNameMap[K] | null
  querySelector<K extends keyof SVGElementTagNameMap>(
    this: Document,
    selectors: K
  ): SVGElementTagNameMap[K] | null
  querySelector<E extends Element = Element>(
    this: Document,
    selectors: string
  ): E | null
}

document.querySelector('.foo') // OK

const $ = document.querySelector
$('.foo') // Compile-time error

// This method hasn't been patched:
const $$ = document.querySelectorAll
$$('.foo') // OK at compile time but results in a runtime error

The compile-time error would be something like this:

The 'this' context of type 'void' is not assignable to method's 'this' of type 'Document'.

Demo on TS Playground

Declaration merging

You could actually copy that partial interface (with only the patched querySelector methods) to your codebase. It would be merged with the default Document interface because of declaration merging.

However, to get comprehensive compile-time safety against "illegal invocation" errors, you would need to:

  • Specify the type of the this keyword for all applicable methods of the Document interface.
  • Do the same for other interfaces as well, e.g. the AbortController interface.

This doesn't feel like it belongs to application code.

Ideally the interfaces would be fixed in TypeScript. Or maybe there could be a library that exports patched interfaces that you could import once in your own code.

Note that in your own methods you can:

  • Declare strict types for the this parameter.
  • Use better error messages than "illegal invocation."
  • Avoid the this parameter altogether.