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.
We will cover:
- What is really TypeScript?
- How typing works in TypeScript?
- Required missing types
- Ban any
any
- The unknown
unknown
type - Required errors checks
- Who is
this
? - Should we add explicit types everywhere?
- Required objects and arrays types
- Required return types
- Dangerous assertions
- Do not use any library
- A better TypeScript lib
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
(instrict
) - ESLint: missing rule
- Biome:
suspicious.noImplicitAnyLet
(inrecommended
)
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
(inrecommended
) - Biome:
suspicious.noExplicitAny
(inrecommended
)
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 (an object instead of a Map
for example).
Required errors checks
- TypeScript:
useUnknownInCatchVariables
(instrict
) - ESLint:
@typescript-eslint/use-unknown-in-catch-callback-variable
(instrict-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. In normal situations, 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.
Who is this
?
- TypeScript:
noImplicitThis
(instrict
) - ESLint:
prefer-arrow-callback
- Biome:
complexity.useArrowFunction
(inrecommended
)
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
(instylistic
) - Biome:
style.noInferrableTypes
(inrecommended
)
For variables assigned to primitive static values, like strings, numbers and booleans, it is superfluous and just a preference. Presets of both TypeScript ESLint and Biome enable 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 types
- 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.
Dangerous assertions
- ESLint:
@typescript-eslint/no-unsafe-type-assertion
- Biome: missing rule
Casting with as
tells the compiler to trust us about a type, without any check. It should be prohibited. For example:
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.
There are justified exceptions. One 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.
Another one is when fixing an "any" coming from a library: casting can serve here to retype correctly.
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.
Do not use any library
- ESLint (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 not reliable enough 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.
Next part
I hope you enjoyed the meta jokes.
In the next part of this posts series, we will explain and solve the second problem of TypeScript default behavior: nullability.
You want to contact me? Instructions are available in the summary.
Top comments (4)
I think
as
still has a use for opaque types. That is the only time I use it, in lieu of real support for them.Hello Michael, thanks for your constructive comment. I think I came across this concept in Angular recently, but I am not used to opaque types, could you provide an example?
I use opaque types for primitive types that should not interchange. For example, if you have unsanitized text you need to sanitize.
Let's say we have a function that should only accept sanitized text:
Both
unsanitizedText
andsanitizedText
are of the same type. There is nothing preventing us from interchanging them, even though only one is valid here.We can intersect
string
with an opaque type to help here.Now we just need to update our functions to use this type. We need to use
as
to tell the compiler to use our distinct type.There are other use cases for opaque types.
Thank you for this very clear and concrete example!