Preventing "illegal invocation" errors in TypeScript
Published on in JavaScript and 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'.
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 theDocument
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.