A TypeScript codebase uses null in some places and undefined in others to mean "no value". As the lead, how do you decide on a single convention, and what does the type system actually do differently with each?
answer
- two distinct types under strictNullChecks
- question mark only ever adds undefined
- JSON can produce null, never undefined
- normalise once at the boundary
- null earns its place for explicit clearing
basics
~20 sPrefer undefined internally, because optional properties and parameters already produce it, and convert null to undefined at the boundaries where JSON brings it in. Keep null only where you genuinely need a third state meaning "explicitly cleared".
solid answer
~40 sThe type system treats them as two distinct types, and TypeScript's own syntax leans one way: `a?: string` and `f(x?: string)` produce `| undefined`, never `| null`, and `Partial<T>` does the same. That makes `undefined` the language's native "absent", so a codebase that standardises on it writes fewer three-way unions. `null` arrives from outside — `JSON.parse` produces it and never produces `undefined` — so the practical convention is: normalise at the boundary, use `undefined` for absence internally, and reserve `null` for the rare case where "explicitly set to nothing" must be distinguishable from "not provided", as in a PATCH body typed `name?: string | null`. Whichever you pick, make the checks uniform: `x != null` excludes both in one test, `?.` and `??` respond to both, and `NonNullable<T>` strips both.
code
typescript · 18 lines// External shape tells the truth about the wire.
type ApiUser = { id: string; bio: string | null };
// Internal shape uses the language's native absent value.
type User = { id: string; bio?: string };
function toUser(raw: ApiUser): User {
return raw.bio === null ? { id: raw.id } : { id: raw.id, bio: raw.bio };
}
// One check covers both at a seam:
function label(bio: string | null | undefined): string {
return bio != null ? bio.trim() : '(none)';
}
// Three real states, deliberately:
type PatchUser = { name?: string | null }; // absent = leave, null = clear
console.log(toUser({ id: '1', bio: null }), label(undefined));go deeper
Know that they are two different types here: a value typed string | null does not accept undefined, and an optional property gives you undefined rather than null.
Explain which constructs produce which — ? and Partial add undefined, JSON.parse produces null — and that x != null, ?. and ?? all handle both in one step.
Show where you would put the normalisation layer so external null stops at the boundary, and defend the choice against the round-trip behaviour of JSON.stringify.
Own the convention as a decision with a cost: write down the rule and its rationale, name the deliberate exception for clear-versus-omit semantics, and choose enforcement that survives team turnover.
## Why this is a decision and not a style quibble Under `strictNullChecks`, `null` and `undefined` are separate types. A value typed `string | null` does not accept `undefined`, and vice versa. Every place a codebase mixes the two conventions, someone eventually writes `string | null | undefined` and then three narrowing branches — and the third branch is the one that gets forgotten. The cost of inconsistency is paid in unions and in checks, forever, so it is worth deciding once. ## What the language itself prefers TypeScript's syntax is not neutral. Every construct that means "this might not be here" produces `undefined`: ```ts interface Profile { bio?: string } // reading bio: string | undefined function greet(title?: string) {} // title: string | undefined type Draft = Partial<Profile>; // every property gains | undefined ``` None of these ever adds `null`. So a codebase that treats `undefined` as "absent" can use the question mark everywhere and never write a union by hand. A codebase that treats `null` as "absent" must write `bio: string | null` explicitly, and then decide separately whether the property may also be missing — which is how `string | null | undefined` gets born. ## What pushes null in from outside `null` is not a stylistic choice at the edges; it is imposed: - `JSON.parse` produces `null` and can never produce `undefined`, because JSON has no such literal. Every REST response is a source of `null`. - `JSON.stringify` drops `undefined` object properties entirely and turns `undefined` array entries into `null`, so a round trip does not preserve your convention. - Database drivers map a nullable column to `null`. - Several DOM APIs return `null` for "not found" — `document.getElementById` is the familiar one. This is what makes "just ban null" naive. `null` will enter the program whatever you decide; the question is *where it stops*. ## The convention that usually wins **Use `undefined` for absence in domain types, and normalise at the boundary.** The parsing or mapping layer that turns a response into a domain object is the single place that knows about `null`: ```ts type ApiUser = { id: string; bio: string | null }; type User = { id: string; bio?: string }; function toUser(raw: ApiUser): User { return { id: raw.id, ...(raw.bio !== null && { bio: raw.bio }) }; } ``` Everything inside the boundary then deals with one flavour of absence, matching what `?`, `Partial` and default parameter values already do. Note the direction: the *external* type keeps `null` because that is the truth about the wire; the *internal* type does not. The opposing convention — `null` everywhere internally — is defensible and some large codebases run it, usually on the argument that an explicit `null` is easier to spot in a debugger than a missing property. It is a coherent choice, but it fights the syntax, so it needs enforcement to stay consistent. ## When you genuinely need both There is one recurring case where two absent-ish values carry different meaning: a partial update. ```ts type PatchUser = { name?: string | null }; // property missing → leave the field alone // property null → clear the field // property string → set the field ``` Here the three states are real business semantics, and collapsing them loses information. Keep this pattern narrow and deliberate — it is a signal at the API boundary, not a licence to mix conventions in domain code. Be aware, too, that by default TypeScript lets you assign `undefined` to an optional property, so "missing" and "present but undefined" are not distinguished unless you turn on `exactOptionalPropertyTypes`; if your PATCH semantics depend on that difference, that flag is the mechanism. ## Make the checks convention-agnostic Whatever you standardise on, defensive code at the seams should handle both, and TypeScript's narrowing supports doing so in one test: ```ts function render(bio: string | null | undefined) { if (bio != null) bio.trim(); // narrows to string — excludes both const shown = bio ?? '(none)'; // ?? falls back for both, not for '' return shown; } ``` `x != null` is the idiomatic single check and the compiler models it precisely. `?.` short-circuits on both. `NonNullable<T>` removes both from a union — worth remembering when someone assumes it only strips `null`. ## Rolling it out A convention nobody enforces reverts within a quarter. Practical levers: pick the rule and write it down with the *reason*; put the normalisation in one mapping layer rather than sprinkling `?? undefined` at call sites; add a lint rule if your toolchain has one for the shape you banned; and treat a hand-written `| null | undefined` union in domain code as a review comment — it almost always means a boundary leaked. Migrate opportunistically rather than in one sweep, and start with the modules where new code is being written, because that is where consistency actually compounds.
- Is there a case where banning null outright is the wrong call?Yes — whenever "explicitly cleared" must differ from "not provided". Partial updates are the classic example: a PATCH body needs missing to mean "leave alone" and null to mean "set to nothing". Some storage layers also distinguish a null column from an absent key. In those places the third state is real information, and collapsing it silently changes behaviour.
- What does NonNullable<T> remove, and why does that trip people up?It removes both `null` and `undefined` from a union, despite the name suggesting only null. So `NonNullable<string | null | undefined>` is `string`. People assume it is null-specific and reach for it expecting `string | undefined` to survive. If you want to strip only one, `Exclude<T, null>` says exactly that and is the honest way to write it.
- How do you keep the convention from eroding once the codebase is large?Put the normalisation in one place — the mapping layer that constructs domain objects — so nobody at a call site is deciding. Then treat a hand-written `| null | undefined` in domain code as a review signal that a boundary leaked. Lint rules help where they exist, but the structural fix is having exactly one layer where external shapes turn into internal ones.
saying these in an interview costs you the question
- Says null and undefined are interchangeable in TypeScript
- Thinks an optional property can be missing or null
- Assumes NonNullable only strips null
- Claims JSON round-trips undefined unchanged
- Bans null without handling where it enters the system