TypeScript strictly typed - Part 2: full coverage typing
Tags: typescript, javascript, productivity, webdev
Published: 2024-06-12 16:53:10

In the [previous part](https://dev.to/cyrilletuzi/typescript-strictly-typed-part-1-configuring-a-project-9ca) of this [posts series](https://dev.to/cyrilletuzi/typescript-strictly-typed-5fln), 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
- Do not use _any_ library
- A better TypeScript lib
- Dangerous assertions

## What is really TypeScript?

This topic requires to understand TypeScript correctly.

The [official TypeScript home page](https://www.typescriptlang.org) 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):

```ts
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:

```ts
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`:

```ts
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`](https://www.typescriptlang.org/tsconfig/#noImplicitAny) (in `strict`)
- ESLint: missing rule
- Biome: [`suspicious.noImplicitAnyLet`](https://biomejs.dev/linter/rules/no-implicit-any-let/) (in `recommended`)

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

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

It enforces explicit types when TypeScript cannot infer automatically:

```ts
// 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:

```ts
let linter; // any
linter = "biome";
```

## Ban any `any`

- ESLint: [`@typescript-eslint/no-explicit-any`](https://typescript-eslint.io/rules/no-explicit-any/) (in `recommended`)
- Biome: [`suspicious.noExplicitAny`](https://biomejs.dev/linter/rules/no-explicit-any/) (in `recommended`)

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

```ts
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`.

```ts
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](https://www.typescriptlang.org/docs/handbook/2/generics.html).

```ts
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`](https://www.typescriptlang.org/tsconfig/#useUnknownInCatchVariables) (in `strict`)
- ESLint: [`@typescript-eslint/use-unknown-in-catch-callback-variable`](https://typescript-eslint.io/rules/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.

```ts
/* 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:

```ts
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`](https://www.typescriptlang.org/tsconfig/#noImplicitThis) (in `strict`)
- ESLint: [`prefer-arrow-callback`](https://eslint.org/docs/latest/rules/prefer-arrow-callback) 
- Biome: [`complexity.useArrowFunction`](https://biomejs.dev/linter/rules/use-arrow-function/) (in `recommended`)

`noImplicitThis` is also about avoiding `any`, for `this`.

```ts
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.

```ts
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`](https://typescript-eslint.io/rules/no-inferrable-types/) (in `stylistic`)
- Biome: [`style.noInferrableTypes`](https://biomejs.dev/linter/rules/no-inferrable-types/) (in `recommended`)

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.

```ts
// 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`](https://typescript-eslint.io/rules/explicit-function-return-type)
- Biome: missing rule ([GitHub issue](https://github.com/biomejs/biome/issues/2017))

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 (in `recommended-type-checked`):
  - [`@typescript-eslint/no-unsafe-argument`](https://typescript-eslint.io/rules/no-unsafe-argument)
  - [`@typescript-eslint/no-unsafe-assignment`](https://typescript-eslint.io/rules/no-unsafe-assignment)
  - [`@typescript-eslint/no-unsafe-call`](https://typescript-eslint.io/rules/no-unsafe-call)
  - [`@typescript-eslint/no-unsafe-member-access`](https://typescript-eslint.io/rules/no-unsafe-member-access)
  - [`@typescript-eslint/no-unsafe-return`](https://typescript-eslint.io/rules/no-unsafe-return)
- 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`](https://github.com/uhyo/better-typescript-lib) by [uhyo](https://github.com/sponsors/uhyo). And what is really magical is that one just needs to:

```bash
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.

## Dangerous assertions

- 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 safety.

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

```ts
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:

```ts
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:

```ts
/* 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:

```ts
/* 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
- disallow dynamic typing

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

You want to **contact me**? Instructions are available in the [summary](https://dev.to/cyrilletuzi/typescript-strictly-typed-5fln).