The Absence of a Value
The flag that catches more real bugs than every other feature combined, and the reason a tree traversal now refuses to compile until you handle the empty case.
By the end: Handle missing values deliberately instead of hoping.
If you turn on exactly one TypeScript feature, make it this one.
strictNullChecks catches more real bugs than every other part of the language combined, because “I did not expect that to be missing” is the single most common runtime failure in JavaScript. Cannot read properties of undefined is the error that has cost the industry the most hours, and this is the flag that makes it a compile error instead.
The difference the flag makes
function initial(name: string | null): string {
// Without strictNullChecks this compiles and crashes at run time on null.
// With it on, the mistake is caught here, before anything runs.
return name.charAt(0);
}
console.log(initial('Ada'));
What the compiler says
error TS18047: 'name' is possibly 'null'.
function initial(name: string | null): string {
if (name === null) return '?';
// Below this line `name` is string, and every string method is available.
return name.charAt(0).toUpperCase();
}
console.log(initial('Ada'));
console.log(initial(null));
Output
null or undefined?
JavaScript has two ways to say nothing, which is one more than anyone wanted. They are genuinely different types in TypeScript, and using them consistently matters more than which convention you pick.
// undefined: the absence you get by default.
let notAssigned: string | undefined;
console.log('declared but unset:', notAssigned);
const sparse: { a?: string } = {};
console.log('missing property: ', sparse.a);
function noReturn(): void {}
console.log('function with no return:', noReturn());
console.log('past the end of an array:', [1, 2].at(5));
// null: an absence somebody wrote down on purpose.
const cleared: string | null = null;
console.log('explicitly emptied:', cleared);
// JSON has null and cannot represent undefined, which is why null turns up
// at every boundary with a server or a database.
console.log('from JSON:', JSON.parse('{"value":null}'));
console.log('undefined does not survive JSON:', JSON.stringify({ a: undefined }));
Output
// `?? ` and `!= null` both treat the two identically, which is usually right.
function displayName(name: string | null | undefined): string {
return name ?? 'anonymous';
}
console.log(displayName('Ada'));
console.log(displayName(null));
console.log(displayName(undefined));
// Normalising at the boundary, so the rest of your code sees only one of them.
function fromApi(raw: { nickname: string | null }): { nickname?: string } {
return raw.nickname === null ? {} : { nickname: raw.nickname };
}
console.log(fromApi({ nickname: 'Ada' }));
console.log(fromApi({ nickname: null }));
Output
The two operators worth knowing properly
Optional chaining
interface Company {
name: string;
address?: {
street?: string;
city: string;
};
}
function cityOf(company: Company): string {
// Without ?. this needs two nested checks. With it, the whole path
// short-circuits to undefined the moment anything is missing.
return company.address?.city ?? 'unknown';
}
const full: Company = { name: 'Acme', address: { city: 'Leeds', street: 'High St' } };
const partial: Company = { name: 'Beta', address: { city: 'York' } };
const bare: Company = { name: 'Gamma' };
console.log(cityOf(full), cityOf(partial), cityOf(bare));
// It works for calls and index access too.
console.log('street:', bare.address?.street ?? 'none');
interface Logger { log?: (msg: string) => void; }
const quiet: Logger = {};
const loud: Logger = { log: (msg) => console.log('logged:', msg) };
// ?.() calls only if the function is there.
quiet.log?.('ignored');
loud.log?.('delivered');
Output
Nullish coalescing
function withOr(count: number | undefined): number {
// || falls through for every falsy value, which includes 0.
return count || 10;
}
function withNullish(count: number | undefined): number {
// ?? falls through only for null and undefined.
return count ?? 10;
}
console.log('undefined:', withOr(undefined), withNullish(undefined));
console.log('5: ', withOr(5), withNullish(5));
console.log('0: ', withOr(0), withNullish(0), ' <- || got this wrong');
// The same trap with strings and the empty string.
const nameOr = (n: string | undefined) => n || 'anonymous';
const nameNullish = (n: string | undefined) => n ?? 'anonymous';
console.log('empty string:', JSON.stringify(nameOr('')), JSON.stringify(nameNullish('')));
Output
interface Options {
retries?: number;
label?: string;
}
function normalise(options: Options): Required<Options> {
// ??= assigns only if the current value is null or undefined.
const merged = { ...options };
merged.retries ??= 3;
merged.label ??= 'task';
return merged as Required<Options>;
}
console.log(normalise({}));
console.log(normalise({ retries: 0 }));
console.log(normalise({ retries: 5, label: 'build' }));
Output
Where this pays off: the tree from the algorithms course
In the algorithms course, every tree traversal begins with a check for the empty tree. Getting that wrong is the classic bug, and in JavaScript nothing reminds you. Here, the compiler will not let you forget.
interface TreeNode {
value: number;
left: TreeNode | null;
right: TreeNode | null;
}
// A depth function missing its base case. In JavaScript this throws the first
// time it reaches a leaf. Here it does not compile.
function depth(node: TreeNode | null): number {
return 1 + Math.max(depth(node.left), depth(node.right));
}
console.log(depth(null));
What the compiler says
error TS18047: 'node' is possibly 'null'. error TS18047: 'node' is possibly 'null'.
interface TreeNode {
value: number;
left: TreeNode | null;
right: TreeNode | null;
}
const leaf = (value: number): TreeNode => ({ value, left: null, right: null });
function depth(node: TreeNode | null): number {
// The one line that makes the whole function correct, and the compiler
// required it rather than trusting you to remember.
if (node === null) return 0;
return 1 + Math.max(depth(node.left), depth(node.right));
}
function sum(node: TreeNode | null): number {
if (node === null) return 0;
return node.value + sum(node.left) + sum(node.right);
}
// 4
// / \
// 2 6
// /
// 1
const tree: TreeNode = {
value: 4,
left: { value: 2, left: leaf(1), right: null },
right: leaf(6),
};
console.log('depth:', depth(tree));
console.log('sum: ', sum(tree));
console.log('empty:', depth(null), sum(null));
Output
Definite assignment, and the cases the compiler cannot see
function classify(n: number): string {
let label: string;
// Every path assigns `label`, and the compiler checks that.
if (n < 0) label = 'negative';
else if (n === 0) label = 'zero';
else label = 'positive';
return label;
}
console.log(classify(-1), classify(0), classify(1));
Output
function classify(n: number): string {
let label: string;
if (n < 0) label = 'negative';
else if (n > 0) label = 'positive';
// n === 0 falls through with label unassigned.
return label;
}
console.log(classify(0));
What the compiler says
error TS2454: Variable 'label' is used before being assigned.
Optional property versus a property that can be undefined
These look identical and are not, and this course has exactOptionalPropertyTypes on so the difference is visible.
// May be absent. Cannot be present-and-undefined.
interface A { name?: string }
// Must be present. May hold undefined.
interface B { name: string | undefined }
// May be absent, and may be present-and-undefined.
interface C { name?: string | undefined }
const a1: A = {};
const a2: A = { name: 'Ada' };
const b1: B = { name: undefined }; // the key is required
const b2: B = { name: 'Ada' };
const c1: C = {};
const c2: C = { name: undefined };
console.log('A:', a1, a2);
console.log('B:', b1, b2);
console.log('C:', c1, c2);
// The distinction is observable: `in` and Object.keys see the difference.
console.log('a1 has the key?', 'name' in a1);
console.log('b1 has the key?', 'name' in b1);
console.log('keys of a1:', Object.keys(a1), 'keys of b1:', Object.keys(b1));
Output
interface A { name?: string }
// Under exactOptionalPropertyTypes, leaving the key out and setting it to
// undefined are different claims, and `?` only permits the former.
const value: A = { name: undefined };
console.log(value);
What the compiler says
error TS2375: Type '{ name: undefined; }' is not assignable to type 'A' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Patterns that keep this pleasant
interface RawUser {
id: number;
name: string | null;
email: string | null;
}
// Inside your program, a user always has a name. The uncertainty lives in
// exactly one place: the function that crosses the boundary.
interface User {
id: number;
name: string;
email?: string;
}
function toUser(raw: RawUser): User | null {
// No name means we cannot build a User at all, and we say so once.
if (raw.name === null) return null;
return raw.email === null
? { id: raw.id, name: raw.name }
: { id: raw.id, name: raw.name, email: raw.email };
}
// Every function past this point takes a User and needs no null checks.
function greet(user: User): string {
return `Hello ${user.name}${user.email ? ` <${user.email}>` : ''}`;
}
const raws: RawUser[] = [
{ id: 1, name: 'Ada', email: 'ada@example.com' },
{ id: 2, name: 'Grace', email: null },
{ id: 3, name: null, email: 'nobody@example.com' },
];
for (const raw of raws) {
const user = toUser(raw);
console.log(user === null ? `#${raw.id}: unusable` : greet(user));
}
Output
interface Config {
host?: string;
port?: number;
}
function connectionString(config: Config): string {
const host = config.host ?? 'localhost';
const port = config.port ?? 5432;
return `${host}:${port}`;
}
console.log(connectionString({}));
console.log(connectionString({ host: 'db.internal' }));
console.log(connectionString({ host: 'db.internal', port: 6543 }));
What this compiles to
function connectionString(config) {
const host = config.host ?? 'localhost';
const port = config.port ?? 5432;
return `${host}:${port}`;
}
console.log(connectionString({}));
console.log(connectionString({ host: 'db.internal' }));
console.log(connectionString({ host: 'db.internal', port: 6543 }));
Output
Open the compiled output. The ?? operators are still there, because they are real JavaScript and do real work. Everything else — the interface, the annotations, the optional markers — is gone. The checking happened at build time; the runtime cost is exactly the two defaults you would have written by hand.
Your turn
- Write a function taking
string | nulland returning its length, or0. Do it with an early return, then again with?.and??on one line. - Write
function fee(amount: number | undefined)that defaults to0. Use||first, then test it with0, then switch to??. - Define the
TreeNodeinterface from this lesson and write acountLeavesfunction. Deliberately omit the base case first and read the error. - Write
interface Settings { theme?: string }and try to construct one with{ theme: undefined }. Note the code. Then change the declaration so it is allowed, and explain to yourself what you just permitted. - Write a
letdeclaration assigned in only two of three branches and read the error. Fix it with anelse, then again with an initial value, and decide which you prefer. - Take a shape with three nullable fields and write the boundary function that turns it into a shape with none. Notice how much checking disappears from everything downstream.
Recap
strictNullChecksmakesnullandundefinedseparate types you must include on purpose, which turns the most common runtime crash in JavaScript into a compile error.- Prefer
undefinedinside your own code and acceptnullat boundaries, because JSON, databases and DOM APIs produce it. ?.guards exactly one link in a path;??replaces onlynullandundefined, where||also discards0,''andfalse.- The compiler checks that every path assigns a variable before it is read. A
!on the declaration switches that off and should carry a comment explaining why. - With
exactOptionalPropertyTypes,name?: stringandname: string | undefinedare different claims and are both observable at run time. - Parse uncertain data into certain data once, at the edge. Then every function downstream needs no checks at all.