TypeScript strictly typed – Part 2: full coverage typing

RMAG news

In the previous part of this posts series, we discussed about how and when to configure a TypeScript project. Now we will explain and solve the first problem of TypeScript default behavior: from partial to full coverage typing.

What is really TypeScript?

This topic requires to understand TypeScript correctly.

The official TypeScript home page defines it as “a strongly typed programming language that builds on JavaScript”.

Everyone knows about the first part of the definition. Fewer are fully aware of the second part, “that builds on JavaScript”, and what it means exactly.

It means that TypeScript is a superset of JavaScript. Told differently: valid JavaScript should be valid TypeScript.

Just change example.js to example.ts and it should be OK! (If by doing so, one gets errors, it would only be because they were doing bad things which were undetected in JavaScript, but never because of a TypeScript syntax problem.)

It was an important decision in TypeScript design, because it is one of the main reasons of its success.

Indeed, if one already knows how to program with JavaScript, they already know how to program with TypeScript. Sure it will be basic TypeScript, but one does not have to learn a whole new language.

How typing works in TypeScript?

But this has a drawback: TypeScript is only partially typed by default.

Let us take a really basic example, which is valid JavaScript (and thus valid TypeScript):

function chocolate(quantity, organic = true) {}

Given that organic parameter has a default value, TypeScript is able to automatically infer that its type is boolean.

But TypeScript is not a seer: it cannot infer the type of quantity parameter.

So with explicit types, the above example is equivalent to this:

function chocolate(quantity: any, organic: boolean = true): void {}

It means that by default, only a portion of the code is really typed. Correctness of what is done with organic variable will be checked, but not what is done with quantity:

function chocolate(quantity: any, organic: boolean = true): void {
// Compilation OK, but runtime error if `quantity` is a number
quantity.toUpperCase();
// Compilation error
organic.toUpperCase();
}

Required missing types

TypeScript: noImplicitAny (in strict)
ESLint: missing rule
Biome: suspicious.noImplicitAnyLet (in recommended)

To fix this default behavior, noImplicitAny is the most important TypeScript compiler option. It is included in strict mode.

// Compilation error in strict mode
function chocolate(quantity, organic = true) {}

It enforces explicit types when TypeScript cannot infer automatically:

// OK
function chocolate(quantity: number, organic = true) {}

Note that noImplicitAny enforces explicit types only when inference is not possible. So it is not required to add explicit types everywhere. But should we? We will discuss that below.

Biome has an additional linter rule noImplicitAnyLet (which does not exist yet in TypeScript ESLint) to catch something which noImplicitAny does not report:

let linter; // any
linter = biome;

Ban any any

ESLint: @typescript-eslint/no-explicit-any (in recommended)
Biome: suspicious.noExplicitAny (in recommended)

noImplicitAny is not strict enough yet. TypeScript still allows this code:

function watch(movie: any): void {
// Runtime error if `movie` is not a string
movie.toUpperCase();
}

any means it can be anything, so TypeScript will let us do anything from that point. One could consider that movie.toUpperCase() is not really TypeScript anymore, but just totally unchecked JavaScript.

So explicit any must be disallowed completely via the linter no-explicit-any rule.

The unknown unknown type

But what to do when one really does not know a data type? The right type to use is unknown.

function watch(movie: unknown): void {
// Compilation error
movie.toUpperCase();

if (typeof movie === string) {
// OK
movie.toUpperCase();
}
}

The difference here is that unknown means what it means: the data type is unknown, so TypeScript will not let us do anything, except if we check the type by ourself.

But note that except for very few special cases, data types are usually known. What happens more frequently is that the type can be variable: it is called generics.

interface ApiData<T> {
error?: string;
data: T;
}

function fetchData<T>(): ApiData<T> {}

fetchData<Movie>();
fetchData<TVSeries>();

Another reason one could be tempted to use any or unknown is when the data structure is too complicated to describe.

Types can serve here as a design warning: if a structure is too complicated to describe as a type, it should probably be simplified, or maybe the wrong concept is used (Record instead of Map for example).

Required catchable errors checks

TypeScript: useUnknownInCatchVariables (in strict)
ESLint: @typescript-eslint/use-unknown-in-catch-callback-variable (in strict-type-checked)
Biome: missing rule

useUnknownInCatchVariables exists because TypeScript cannot be sure at compilation time what will be the type of errors in catch blocks.

/* In default mode */
try {
someAction();
} catch (error) { // `any` type
// Runtime error if not an `Error`
error.message;
}

/* In strict mode */
try {
someAction();
} catch (error) { // `unknown` type
// Compilation error
error.message;

// OK
if (error instanceof Error) {
error.message;
}
}

The same issue happens in the asynchronous version in Promises, but is not handled by the former option.

The linter use-unknown-in-catch-callback-variable rule enforces to do it:

fetch(/api).catch((error: unknown) => {});

Note that it is an exceptional case to override a non-strict enough default behavior. Otherwise, typing callback functions parameters should not be done like this: it is the responsibility of the outer function to type the callback function, including its parameters.

this is not any body

TypeScript: noImplicitThis (in strict)
ESLint: prefer-arrow-callback
Biome: complexity.useArrowFunction (in recommended)

noImplicitThis is also about avoiding any, for this.

class Movie {

title = The Matrix;

displayTitle(): void {

window.setTimeout(function () {
// Runtime error in default mode,
// because `this` has changed and is no more the class instance
this.title;
}, 3000);

}

}

But note that it happens because the code above is not using correct and modern JavaScript. Arrow functions should be used to keep the this context.

class Movie {

title = The Matrix;

displayTitle(): void {

window.setTimeout(() => {
// OK, `this` is still the class instance
this.title;
}, 3000);

}

}

Arrow syntax can be enforced by the linter prefer-arrow-callback rule.

Should we add explicit types everywhere?

ESLint: @typescript-eslint/no-inferrable-types (in stylistic)
Biome: style.noInferrableTypes (in recommended)

For variables assigned to primitive static values, like strings, numbers and booleans, it is superfluous and just a preference. The stylistic presets of both TypeScript ESLint and Biome enables the no-inferrable-types rule, which disallows explicit types in this case, to keep the code concise.

But developers from Java, C# or Rust, who are accustomed to explicit types nearly everywhere, can make the choice to do the same in TypeScript and to disable this rule.

Required objects and arrays type

ESLint: missing rule
Biome: missing rule

On the other hand, when it is a more complex structure, like arrays and objects, it is better to use explicit types. TypeScript can always infer a type when there is a value, but the inference happens based on what the code does. So we presuppose the code is doing things right.

// Bad
const inferred = [hello, 81];
// Good: classic array, not mixing types
const explicitArray: string[] = [hello, world];
// Good: tuple (although rarely the best solution)
const explicitArray:[string, number] = [hello, 81];

// Bad
const movieWithATypo = {
totle: Everything everywhere all at once,
};
// Good
interface Movie {
title: string;
}
const checkedMovie: Movie = {
title: Everything everywhere all at once,
};

Required return types

ESLint: @typescript-eslint/explicit-function-return-type

Biome: missing rule (GitHub issue)

Same goes with functions: TypeScript is always able to infer the return type, but it does so based on what the function does. If the function is modified, the return type will continue to be inferred, but the modifications may have change this type by error.

The linter explicit-function-return-type rule enforces to explicitly type the functions returns.

It is also considered a good practice because the return type is part of the core and minimal documentation one should include for every function.

Do not use any library

ESLint:

@typescript-eslint/no-unsafe-argument (in recommended-type-checked)

@typescript-eslint/no-unsafe-assignment (in recommended-type-checked)

@typescript-eslint/no-unsafe-call (in recommended-type-checked)

@typescript-eslint/no-unsafe-member-access (in recommended-type-checked)

@typescript-eslint/no-unsafe-return (in recommended-type-checked)

Biome: missing rules

Now our code has full coverage typing. But in a real world project, frameworks and libraries are also included. What if they introduced some any?

Some other linter no-unsafe-xxx rules catch when something typed as any is used.

But as a prior step, one should audit libraries carefully before adding them in a project. Accumulating unreliable libraries is another major recurring problems in JavaScript projects.

It can be made into a criterion of choice: one can go see the tsconfig.json and the lint configuration in the library GitHub repository to check if it is typed in a strict way. One should not be too demanding though: currently there are probably no libraries following all the recommendations from this posts series. At the current state of the TypeScript ecosystem, having the strict mode and the no-explicit-any rule is already a lot.

A better TypeScript lib

There is one hole left in our typing coverage: what about the types of native JavaScript functions and classes?

Few knows it, but if our code editor knows what is the type expected by JSON.parse() for example, it is because TypeScript includes definitions for every JavaScript native API, in what is called “libs”.

In Visual Studio Code, if we cmd/ctrl click on parse(), we arrive in a file called lib.es5.d.ts, with definitions for a lot of JavaScript functions. And in this example, we see any as the return type.

Those any have a historical reason (for example, unknown did not exist in the very first versions of TypeScript). And correcting that now would break a lot of existing projects, so it is unlikely to happen.

But even fewer knows these definitions can be overridden. It is what does the amazing better-typescript-lib by uhyo. And what is really magical is that one just needs to:

npm install better-typescript-lib –save-dev

and we are done! Now JavaScript native APIs will be typed correctly with no more any.

To be transparent: I discovered this wonder very recently. My first usages of it were successful, but I have little perspective on it yet. But it is not a big risk: in worst case scenario, just uninstall the library and that is it.

Note that the use-unknown-in-catch-callback-variable lint rule becomes useless with this tool.

Typing as any is evil

ESLint: missing rule
Biome: missing rule

One may think that with all the rules we talked about, we could be sure of full coverage typing. Yet there are still some bad practices in TypeScript which can break type correctness.

Casting with as tells the compiler to trust us about a type, without any check. It should be prohibited.

It mostly happens for casting as a child class:

const input = document.querySelector(#some-input) as HTMLInputElement;

// Runtime error if it is not really an input
// or if it does not exist
input.value;

// OK
if (input instanceof HTMLInputElement) {
input.value;
}

The worst thing which can be done is this:

movie as unknown as TVSeries;

TypeScript sometimes suggests to do that in some scenarios, and it is a terrible suggestion. Like any, it is basically bypassing all type checks and going back to blind JavaScript.

The only justified exception to this is when implementing a HTTP client: TypeScript cannot know the type of the JSON sent by the server, so we have to tell it. Still, we are responsible that the client forced type matches the server model.

Type predicates is just a variant of type assertions for functions returns. The most common case is when filtering an array:

/* With TypeScript <= 5.4 */
const movies: (string | undefined)[] = [];

movies
.filter((movie) => movie !== undefined)
.map((movie) => {
// string | undefined
// because TypeScript is not capable to narrow the type
// based on what the code do in `filter`
movie;
});

movies
.filter((movie): movie is string => movie !== undefined)
.map((movie) => {
// string
movie;
});

Nice, but the compiler trusts us. If the check does not match the type predicate, errors may happen.

While this feature can be useful and relevant in some cases, it should be double checked when used.

Note that I took the filter example because it is the most frequent one. But now:

/* With TypeScript >= 5.5 */
const movies: (string | undefined)[] = [];

movies
.filter((movie) => movie !== undefined)
.map((movie) => {
// string
movie;
});

Be sure when updating to TypeScript 5.5 to delete movie is string, because the behavior is not exactly the same. Explicit movie is string still means the compiler blindly trusts us. But the new implicit behavior is inferred from the actual code in filter, so now the type predicate is really checked!

It also means it becomes an exception of the linter explicit-function-return-type rule: in such scenarios, the return type should not be explicit.

Next part

I hope you enjoyed the meta jokes. But we still have 2 other problems to solve:

handle nullability
enforce static typing

Next chapters will be published soon, you can follow my account (button on top right of this page).

You want to comment or contact me? Instructions are available in the summary.