DEV Community

Cover image for TypeScript strictly typed - Part 2: full coverage typing
Cyrille Tuzi
Cyrille Tuzi

Posted on

TypeScript strictly typed - Part 2: full coverage typing

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
  • 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 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) {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Required missing types

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) {}
Enter fullscreen mode Exit fullscreen mode

It enforces explicit types when TypeScript cannot infer automatically:

// OK
function chocolate(quantity: number, organic = true) {}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

Ban any any

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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) => {});
Enter fullscreen mode Exit fullscreen mode

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?

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);

  }

}
Enter fullscreen mode Exit fullscreen mode

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);

  }

}
Enter fullscreen mode Exit fullscreen mode

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

Should we add explicit types everywhere?

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",
};
Enter fullscreen mode Exit fullscreen mode

Required return types

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

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
Enter fullscreen mode Exit fullscreen mode

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:

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;
}
Enter fullscreen mode Exit fullscreen mode

The worst thing which can be done is this:

movie as unknown as TVSeries;
Enter fullscreen mode Exit fullscreen mode

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;
  });
Enter fullscreen mode Exit fullscreen mode

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;
  });
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (4)

Collapse
 
michael_theriot profile image
Michael Theriot

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.

Collapse
 
cyrilletuzi profile image
Cyrille Tuzi

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?

Collapse
 
michael_theriot profile image
Michael Theriot

I use opaque types for primitive types that should not interchange. For example, if you have unsanitized text you need to sanitize.

const sanitizeText = (unsanitized: string): string => {
    return unsanitized.replaceAll("unsanitized", "sanitized");
};

const unsanitizedText = "dummy unsanitized text";
const sanitizedText = sanitizeText(unsanitizedText);
Enter fullscreen mode Exit fullscreen mode

Let's say we have a function that should only accept sanitized text:

const outputToUser = (sanitized: string) => {
  // do something
};
Enter fullscreen mode Exit fullscreen mode

Both unsanitizedText and sanitizedText are of the same type. There is nothing preventing us from interchanging them, even though only one is valid here.

outputToUser(unsanitizedText); // oops
Enter fullscreen mode Exit fullscreen mode

We can intersect string with an opaque type to help here.

// an ugly hack to create a distinct type that is really just a string
declare const _sanitized: unique symbol;
type SanitizedString = string & { readonly [_sanitized]: never };
Enter fullscreen mode Exit fullscreen mode

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.

const sanitizeText = (unsanitized: string): SanitizedString => {
    return unsanitized.replaceAll("unsanitized", "sanitized") as SanitizedString;
};

const outputToUser = (sanitized: SanitizedString) => {
  // do something
};

const unsanitizedText = "dummy unsanitized text";
const sanitizedText = sanitizeText(unsanitizedText);

outputToUser(sanitizedText); // OK
outputToUser(unsanitizedText); // compiler error!
Enter fullscreen mode Exit fullscreen mode

There are other use cases for opaque types.

Thread Thread
 
cyrilletuzi profile image
Cyrille Tuzi

Thank you for this very clear and concrete example!