TypeScript strictly typed - Part 3: safe nullability
Published: 2024-07-17 12:39:35

In the [previous part](https://dev.to/cyrilletuzi/typescript-strictly-typed-part-2-full-coverage-typing-4cg1) of this [posts series](https://dev.to/cyrilletuzi/typescript-strictly-typed-5fln), 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

- TypeScript: [`strictNullChecks`](https://www.typescriptlang.org/tsconfig/#strictNullChecks) (in `strict`)
- ESLint: [`@typescript-eslint/prefer-optional-chain`](https://typescript-eslint.io/rules/prefer-optional-chain/) (in `stylistic-type-checked`)
- Biome: [`complexity.useOptionalChain`](https://biomejs.dev/linter/rules/use-optional-chain/) (in `recommended`)

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

```ts
/* 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", () => {});
```

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.

```ts
/* 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", () => {});
```

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

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

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

## Required properties initialization
- TypeScript: [`strictPropertyInitialization `](https://www.typescriptlang.org/tsconfig/#strictPropertyInitialization) (in `strict`)

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

```ts
/* In default mode */

class Movie {
  title: string;

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

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

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

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

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

```ts
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();
```

## Objects vs records
- TypeScript: [`noPropertyAccessFromIndexSignature `](https://www.typescriptlang.org/tsconfig/#noPropertyAccessFromIndexSignature)

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.

```ts
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",
};
```

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:

```ts
type CssValue = Record<string, string>;

const css: CssValue = {
  color: "blue",
  border: "1px solid red",
};
```

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

```ts
// Compilation error with `noPropertyAccessFromIndexSignature`
css.color;
// OK
css["color"];
```

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:

```ts
type CssValue = Map<string, string>;

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

css.get("color"); // string | undefined!
```

## Required indexes checks
- TypeScript: [`noUncheckedIndexedAccess`](https://www.typescriptlang.org/tsconfig/#noUncheckedIndexedAccess)
- ESLint: [`@typescript-eslint/prefer-for-of`](https://typescript-eslint.io/rules/prefer-for-of/) (in `stylistic`)
- Biome: [`style.useForOf`](https://biomejs.dev/linter/rules/use-for-of/)

Let us continue to talk about records.

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

const css: CssValue = {
  color: "blue",
};
css["color"]; // string
css["border"]; // string
```

Spotted the issue? The last line is obviously erroneous.

`noUncheckedIndexedAccess` will do the job correctly:

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

const css: CssValue = {
  color: "blue",
};
css["color"]; // string | undefined
css["border"]; // string | undefined
```

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:

```ts
type CssValue = Record<string, string>;

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

css.color; // string
css.border; // Compilation error
```

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:

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

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

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

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

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

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

for (const movie of movies) {
  movie; // string
}
```

Need the index?

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

for (const [index, movie] of movies.entries()) {
  movie; // string
}
```

## Exact properties types
- TypeScript: [`exactOptionalPropertyTypes `](https://www.typescriptlang.org/tsconfig/#exactOptionalPropertyTypes)

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

```ts
interface Movie {
  title?: string;
  title: string | undefined;
}
```

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:

```ts
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) {}
```

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

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

```ts
interface Movie {
  title?: string;
  title: string | undefined;
  title?: string | undefined;
}
```

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:

```ts
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,
});
```

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

```ts
/* 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 } : {}),
});
```

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.

```ts
interface MovieFromJSON {
  speed?: number;
  subtitles?: boolean;
}
```

## Handling inexact libraries

- TypeScript: [`skipLibCheck`](https://www.typescriptlang.org/tsconfig/#skipLibCheck)

`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 !

- ESLint: [`@typescript-eslint/no-non-null-assertion`](https://typescript-eslint.io/rules/no-non-null-assertion/) (in `strict`)
- Biome: [`style.noNonNullAssertion`](https://biomejs.dev/linter/rules/no-non-null-assertion/) (in `recommended`)

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

```ts
// Runtime error if undefined
unsureMovie!.watch();
```

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:

```ts
unsureMovie?.watch();
```

## Next part

We still have 1 other problem to solve:
- disallow dynamic typing

The next chapter 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).