TypeScript strictly typed - Part 3: safe nullability
Cyrille Tuzi
Posted on July 17, 2024
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
- TypeScript:
strictNullChecks
(instrict
) - ESLint:
@typescript-eslint/prefer-optional-chain
(instylistic-type-checked
) - Biome:
complexity.useOptionalChain
(inrecommended
)
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", () => {});
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", () => {});
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;
}
We will discuss the difference between the last 3 forms below.
Required properties initialization
- TypeScript:
strictPropertyInitialization
(instrict
)
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();
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;
}
}
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();
Objects vs records
- TypeScript:
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.
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:
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:
// 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:
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
- ESLint:
@typescript-eslint/prefer-for-of
(instylistic
) - Biome:
style.useForOf
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
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
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
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
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
}
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
}
Need the index?
const movies: string[] = [`The Matrix`];
for (const [index, movie] of movies.entries()) {
movie; // string
}
Exact properties types
- TypeScript:
exactOptionalPropertyTypes
Most people think the below 2 ways to define a property are equivalent:
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:
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:
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:
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.
/* 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.
interface MovieFromJSON {
speed?: number;
subtitles?: boolean;
}
Handling inexact libraries
- TypeScript:
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
(instrict
) - Biome:
style.noNonNullAssertion
(inrecommended
)
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();
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();
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.
Posted on July 17, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.