TypeScript strictly typed - Part 3: safe nullability

cyrilletuzi

Cyrille Tuzi

Posted on July 17, 2024

TypeScript strictly typed - Part 3: safe nullability

In the previous part of this posts series, we discussed about full coverage typing.

Now we will explain and solve the second problem of TypeScript default behavior: unsafe nullability.

We will cover:

  • Required nullability checks
  • Required properties initialization
  • Objects vs records
  • Required indexes checks
  • Exact properties types
  • Handling inexact libraries
  • Evil !

Required nullability checks

In JavaScript, no matter what is the type of a variable, it can always contain null or undefined. It leads to errors:

/* In default mode */

let movie: string = "The Matrix";
// OK
movie = null;
// Runtime error
movie.toUpperCase();

// Runtime error if the id does not exist
document.querySelector("#wrong-id")
  .addEventListener("click", () => {});
Enter fullscreen mode Exit fullscreen mode

With strictNullChecks, the second most important TypeScript compiler option, a type cannot be null or undefined unless it is explicitly authorized, and TypeScript will enforce checks for values which can be nullish.

/* In strict mode */

let movie: string = "The Matrix";
// Compilation error
movie = null;

let nullableMovie: string | null = "The Matrix";
// OK
nullableMovie = null;

// Compilation error
document.querySelector("#wrong-id")
  .addEventListener("click", () => {});
// OK
document.querySelector("#wrong-id")
  ?.addEventListener("click", () => {});
Enter fullscreen mode Exit fullscreen mode

As in the above example, modern JavaScript, with the optional chaining operator, will help a lot to handle nullability without introducing more conditions and complexity. It can be enforced by the prefer-optional-chain lint rule.

It also happens a lot with objects, for which some properties can be optional and/or undefined.

/* In strict mode */

interface Movie {
  // Required property
  title: string;
  // Optional property
  summary?: string;
  // Required property which can be undefined
  summary: string | undefined;
  // Optional property which can be undefined
  summary?: string | undefined;
}
Enter fullscreen mode Exit fullscreen mode

We will discuss the difference between the last 3 forms below.

Required properties initialization

strictPropertyInitialization is just a variant of strictNullChecks for class properties. Let us look at this example:

/* In default mode */

class Movie {
  title: string;

  getUpperCasedTitle() {
    return this.title.toUpperCase();
  }
}

const movie = new Movie();
// Runtime error because `title` is undefined
movie.getUpperCasedTitle();
Enter fullscreen mode Exit fullscreen mode

If a class property is not initialized (either in its declaration or in the constructor), then it can be undefined.

/* In strict mode */

class Movie {
  // Compilation error
  titleNotInitialized: string;
  // OK
  titleInitializedDirectly = "The Matrix";
  titleInitializedInConstructor: string;
  titleOptional: string | undefined;

  constructor(title: string): void {
    this.titleInitializedInConstructor = title;
  }
}
Enter fullscreen mode Exit fullscreen mode

It can get complicated if initialization involves some asynchronous actions, as a class constructor cannot be asynchronous. This pattern can be used:

class Movie {
  title: string;

  // Do not allow direct instantiation
  // to enforce the use of `getInstance()`
  private constructor(title: string) {
    this.title = title;
  }

  static async getInstance(): Movie {
    const title = await fetchTitle();
    return new this(title);
  }
}

const movie = await Movie.getInstance();
Enter fullscreen mode Exit fullscreen mode

Objects vs records

Technically noPropertyAccessFromIndexSignature does not impact type correctness, but it is closely related to the next option, noUncheckedIndexedAccess, so we will explain it first.

Most objects have a defined interface with known properties. But sometimes an object with dynamic properties is needed.

interface CssValue {
  // It means the object can have any property
  // with a string index and a string value
  [key: string]: string;
}

const css: CssValue = {
  color: "blue",
  border: "1px solid red",
};
Enter fullscreen mode Exit fullscreen mode

Note that the code above is a legacy syntax. TypeScript introduced a proper concept for this case called Record, with a simpler and recommended syntax:

type CssValue = Record<string, string>;

const css: CssValue = {
  color: "blue",
  border: "1px solid red",
};
Enter fullscreen mode Exit fullscreen mode

As it is indeed a different concept, noPropertyAccessFromIndexSignature enforces a special syntax to access such dynamic properties:

// Compilation error with `noPropertyAccessFromIndexSignature`
css.color;
// OK
css["color"];
Enter fullscreen mode Exit fullscreen mode

Why a specific syntax? To be sure to take into account that color may not exist, as dynamic properties allow any property.

Note that in most cases, records would be better expressed as maps, which is a real proper concept in JavaScript:

type CssValue = Map<string, string>;

const css: CssValue = new Map([
  ["color", "blue"],
  ["border", "1px solid red"],
]);

css.get("color"); // string | undefined!
Enter fullscreen mode Exit fullscreen mode

Required indexes checks

Let us continue to talk about records.

/* In default mode */
type CssValue = Record<string, string>;

const css: CssValue = {
  color: "blue",
};
css["color"]; // string
css["border"]; // string
Enter fullscreen mode Exit fullscreen mode

Spotted the issue? The last line is obviously erroneous.

noUncheckedIndexedAccess will do the job correctly:

/* With `noUncheckedIndexedAccess` */
type CssValue = Record<string, string>;

const css: CssValue = {
  color: "blue",
};
css["color"]; // string | undefined
css["border"]; // string | undefined
Enter fullscreen mode Exit fullscreen mode

Now the last line is correct.

But notice that color is now considered to potentially be undefined too, which will require additional checks.

Why? Because the css variable is typed as Record<string, string>, which does not ensure the existence of any specific property. The explicit generic record type somewhat overrides the more specific type which TypeScript could infer from the concrete value.

One cannot just delete the explicit type: it would mean that the object is not checked at all and could contain errors (for example, having boolean values).

But there is a solution:

type CssValue = Record<string, string>;

const css = {
  color: "blue",
} satisfies CssValue;

css.color; // string
css.border; // Compilation error
Enter fullscreen mode Exit fullscreen mode

And notice that as TypeScript is now sure there is a color property, it also allows us to come back to the classic dot syntax!

As a reminder, all of this would have been avoided with a Map instead of a record.

noUncheckedIndexedAccess also applies to array indexes:

const movies: string[] = [`The Matrix`];

/* In default mode */
movies[3]; // string
/* With `noUncheckedIndexedAccess` */
movies[3]; // string | undefined
Enter fullscreen mode Exit fullscreen mode

It can causes issues in some places where TypeScript cannot infer information at compilation time, because it depends on concrete values at runtime:

/* With `noUncheckedIndexedAccess` */
for (let i = 0; i < movies.length; i++) {
  movies[i]; // string | undefined
}
Enter fullscreen mode Exit fullscreen mode

But it is easily avoided by writing modern JavaScript, which can be enforced by the prefer-for-of lint rule:

const movies: string[] = [`The Matrix`];

for (const movie of movies) {
  movie; // string
}
Enter fullscreen mode Exit fullscreen mode

Need the index?

const movies: string[] = [`The Matrix`];

for (const [index, movie] of movies.entries()) {
  movie; // string
}
Enter fullscreen mode Exit fullscreen mode

Exact properties types

Most people think the below 2 ways to define a property are equivalent:

interface Movie {
  title?: string;
  title: string | undefined;
}
Enter fullscreen mode Exit fullscreen mode

In TypeScript default mode, they are equivalent. But in reality, at JavaScript runtime, they are not.

title?: string; means that the property title may not exist in the object. But if it exists, it will always be a string.

title: string | undefined; means that the property title will always exist in the object. But its value may be a string or undefined.

In some scenarios, it gives different results:

class Movie {
  optionalStringTitle?: string;
  requiredStringOrUndefinedTitle: string | undefined;
}

const movie: Movie = {
  requiredStringOrUndefinedTitle: undefined,
};

// false
if ("optionalStringTitle" in movie) {}
// true, but the value is `undefined`
if ("requiredStringOrUndefinedTitle" in movie) {}
Enter fullscreen mode Exit fullscreen mode

exactOptionalPropertyTypes enforces to manage these differences correctly, so it reflects the actual runtime behavior.

When enabled, it also means a third scenario is possible:

interface Movie {
  title?: string;
  title: string | undefined;
  title?: string | undefined;
}
Enter fullscreen mode Exit fullscreen mode

Question is: which one to choose?

Whenever the data is a parameter of something (functions mainly), the third form should be chosen to allow both possibilities.

Why? Because the function consumer may be forced to use one form, and they should be able to use the function without additional transformations:

interface MovieOptions {
  speed?: number | undefined;
  subtitles?: boolean | undefined;
}

function watchMovie(options: MovieOptions): void {}

/* All the code below should be OK */

watchMovie({
  // No `speed` property
  subtitles: true,
});

// May be `undefined`
const subtitles = configFromPreviousCode?.subtitles;
watchMovie({
  subtitles,
});
Enter fullscreen mode Exit fullscreen mode

Otherwise, it will be more complicated to manage some scenarios. Let us imagine a common scenario: library code which does not allow undefined values.

/* Some library */
interface MovieOptions {
  speed?: number;
  subtitles?: boolean;
}

function watchMovie(options: MovieOptions): void {}

/* User code */
const subtitles = configFromPreviousCode?.subtitles;

watchMovie({
  // Compilation error with `exactOptionalPropertyTypes`
  subtitles,
  // OK
  ...(subtitles ? { subtitles } : {}),
});
Enter fullscreen mode Exit fullscreen mode

So it is particularly important from frameworks and libraries author to be aware of this issue.

Conversely, when owned data is described, it should be described as exactly as possible. For example, when getting JSON data from a HTTP request, some properties will be optional, but it is unlikely that an object contains a property with undefined as a value.

interface MovieFromJSON {
  speed?: number;
  subtitles?: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Handling inexact libraries

exactOptionalPropertyTypes and noUncheckedIndexedAccess seem to fix some TypeScript incorrectness. So one could ask why it is not included in strict mode or in strictNullChecks.

From what I know, it is mostly for backward compatibility with existing libraries. Indeed, one will probably meet some library code causing issues here and there, especially with exactOptionalPropertyTypes.

So should a project sacrifice some strict options and thus correctness because it includes a non-strict-enough library?

Hopefully not. Apart from creating an issue and a Pull Request to fix it in the library repository, the skipLibCheck compiler option can also be enabled.

It will skip type checks inside library code (meaning code in node_modules) and just type check the project own code.

Most frameworks already enable this option by default for performance (less checks = faster compilation), and it is a recommended option in the official TypeScript documentation.

Evil !

In TypeScript, the ! operator tells the compiler to trust us about the fact that a data is not null or undefined, without any checks.

// Runtime error if undefined
unsureMovie!.watch();
Enter fullscreen mode Exit fullscreen mode

It is basically destroying all the nullability safety we talked above. So, of course, it should be prohibited by the no-non-null-assertion lint rule.

In most cases, undefined values must be checked with the ? operator:

unsureMovie?.watch();
Enter fullscreen mode Exit fullscreen mode

Next part

In the next part of this posts series, we will explain and solve the third and last problem of TypeScript default behavior: remains of dynamic typing.

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

💖 💪 🙅 🚩
cyrilletuzi
Cyrille Tuzi

Posted on July 17, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related