Emmanuel Akinfulubi

Lesson 08 of 1818 min read

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

A value that might be null, used as though it were not

TS18047
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'.

Handle it, and the error goes away

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.

Where each one comes from

// 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


      

Accepting both, when you cannot control the source

// `?? ` 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

?. stops at the first missing link

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

?? versus || , and why the difference matters

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


      

?? = for assigning only when absent

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.

The base case is no longer optional

TS18047
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'.

The base case, and the compiler is satisfied

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

A variable assigned in a branch the compiler can follow

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


      

A path where it is not assigned

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

Three subtly different declarations

// 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


      

An optional property will not take an explicit undefined

TS2375
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

Push the check to the edge and stop repeating it

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


      

What all of this compiles to

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

  1. Write a function taking string | null and returning its length, or 0. Do it with an early return, then again with ?. and ?? on one line.
  2. Write function fee(amount: number | undefined) that defaults to 0. Use || first, then test it with 0, then switch to ??.
  3. Define the TreeNode interface from this lesson and write a countLeaves function. Deliberately omit the base case first and read the error.
  4. 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.
  5. Write a let declaration assigned in only two of three branches and read the error. Fix it with an else, then again with an initial value, and decide which you prefer.
  6. 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

  • strictNullChecks makes null and undefined separate types you must include on purpose, which turns the most common runtime crash in JavaScript into a compile error.
  • Prefer undefined inside your own code and accept null at boundaries, because JSON, databases and DOM APIs produce it.
  • ?. guards exactly one link in a path; ?? replaces only null and undefined, where || also discards 0, '' and false.
  • 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?: string and name: string | undefined are 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.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.