← Concepts & practices
Choice Data modeling and type design

Absence: null vs undefined vs missing

What does “no value” ask you to do?

A missing field can mean “leave it alone.” A null field can mean “clear it.” JavaScript adds undefined before the request is serialized, but the wire may erase that distinction. Keep the meanings separate until you know which ones the contract actually needs.

The judgment to keep

Choose a representation from the operation it must express, then test what survives the boundary.

TypeScriptGo One field. Three meanings to preserve.
Start with one update

Empty-looking is not one meaning.

A profile editor sends a PATCH for bio. If the editor sends no bio key, the server should leave the saved bio alone. If it sends null, the user chose to clear the bio. A string replaces it, including an empty string if the product permits that.

In JavaScript, { bio: undefined } has an own property that reads undefined. But JSON.stringify omits that property, so the receiver sees the same JSON as an object where the key was never present.

Our shared contractUpdate the profile bio

We keep the stored profile fixed while changing the incoming representation.

Missing key
Keep the current bio.
bio: null
Clear the bio.
bio: string
Set the new bio.
Put the choices on the same table

Three ways to carry the meaning.

These options can combine. Presence plus null is a compact wire format; an explicit operation is useful when the vocabulary needs to grow. The question is what a receiver can distinguish and what the producer must maintain.

A

Treat nullish as one case

Use a default such as patch.bio ?? current. It is concise when null and missing both mean keep.

Cannot express clear with null.
B

Presence plus null

Check whether the key exists, then let null mean clear and a string mean set.

Small and JSON-friendly; preserve presence before transformations erase it.
C

Explicit operation

Send keep, clear, or set as a tagged command, and decode it with decodeBioOperation.

Portable and extensible; pays a larger public payload.
What does each strategy let the receiver know?
StrategyMissingNullCost / limit
Nullish defaultKeepKeepSimple, but no clear operation.
Presence + nullKeepClearCompact; JSON key presence must be inspected.
Explicit operationNeeds a keep operationNeeds a decoder policyMore bytes and vocabulary; easiest to extend deliberately.
Keep the profile fixed

Now change one value.

The happy string works everywhere. Select null and watch defaulting lose the clear command. Select undefined and watch serialization erase the property before it reaches a JSON receiver.

Constraint lab

What does an empty-looking value mean?

Keep the profile update fixed. Change the value or the interpretation.

Incoming value
Interpretation
In memory
{ bio: null }
After JSON.stringify
{"bio":null}

Choose a value and interpretation, then apply the update.

Why is undefined not a wire value?JavaScript before JSON

undefined can be useful inside a form or function call. JSON has null, strings, numbers, booleans, arrays, and objects; it does not have an undefined property value. An object property with undefined is omitted by JSON.stringify, while an array slot becomes null. If that difference matters, define an explicit operation before serialization.

Separate the two comparisons

Hold the meaning steady. Change the language.

Read the patch in TypeScript and Go.

The lab runs TypeScript. These panes show how each language preserves the same update contract.

Name the representations

TypeScriptPatch representations
profile.ts · representations
export type ProfilePatch = {
	bio?: string | null;
};

export type ExplicitBioPatch = { op: 'keep' } | { op: 'clear' } | { op: 'set'; value: string };
GoPatch representations
profile.go · representations
// A pointer alone cannot tell a missing JSON key from an explicit null.
type NaiveProfilePatch struct {
	Bio *string `json:"bio"`
}

type BioChange struct {
	Kind  string
	Value string
}

type Profile struct {
	Bio *string
}

A TypeScript optional property and a Go pointer are convenient, but neither alone gives the receiver all three meanings.

Decode before updating

TypeScriptPatch decoder
profile.ts · interpretation
export function applyNullish(profile: Profile, patch: BioPatch): Profile {
	return { bio: patch.bio ?? profile.bio };
}

export function applyPresence(profile: Profile, patch: BioPatch): Profile {
	if (!Object.hasOwn(patch, 'bio') || patch.bio === undefined) return profile;
	return { bio: patch.bio };
}

function isRecord(value: unknown): value is Record<string, unknown> {
	return typeof value === 'object' && value !== null && !Array.isArray(value);
}

export function decodeBioPatch(value: unknown): DecodeResult {
	if (!isRecord(value)) return { ok: false, field: 'body', message: 'Expected a JSON object.' };
	if (!Object.hasOwn(value, 'bio') || value.bio === undefined) {
		return { ok: true, change: { kind: 'keep' } };
	}
	if (value.bio === null) return { ok: true, change: { kind: 'clear' } };
	if (typeof value.bio === 'string') return { ok: true, change: { kind: 'set', value: value.bio } };
	return { ok: false, field: 'bio', message: 'Bio must be a string or null.' };
}

/** Alternative C: the wire carries the operation itself, such as { "op": "clear" }. */
export function decodeBioOperation(value: unknown): DecodeResult {
	if (!isRecord(value)) return { ok: false, field: 'body', message: 'Expected a JSON object.' };
	switch (value.op) {
		case 'keep':
		case 'clear':
			return { ok: true, change: { kind: value.op } };
		case 'set':
			return typeof value.value === 'string'
				? { ok: true, change: { kind: 'set', value: value.value } }
				: { ok: false, field: 'value', message: 'A set operation needs a string value.' };
		default:
			return { ok: false, field: 'op', message: 'Op must be keep, clear, or set.' };
	}
}

export function applyChange(profile: Profile, change: BioChange): Profile {
	switch (change.kind) {
		case 'keep':
			return profile;
		case 'clear':
			return { bio: null };
		case 'set':
			return { bio: change.value };
	}
}
GoPatch decoder
profile.go · decoder
func decodeBioPatch(payload []byte) (BioChange, error) {
	var fields map[string]json.RawMessage
	if err := json.Unmarshal(payload, &fields); err != nil || fields == nil {
		return BioChange{}, fmt.Errorf("body: expected a JSON object")
	}
	raw, present := fields["bio"]
	if !present {
		return BioChange{Kind: "keep"}, nil
	}
	if string(raw) == "null" {
		return BioChange{Kind: "clear"}, nil
	}
	var bio string
	if err := json.Unmarshal(raw, &bio); err != nil {
		return BioChange{}, fmt.Errorf("bio: expected a string or null")
	}
	return BioChange{Kind: "set", Value: bio}, nil
}

// decodeBioOperation is alternative C: the wire carries the operation itself,
// such as {"op":"clear"} or {"op":"set","value":"Updated."}.
func decodeBioOperation(payload []byte) (BioChange, error) {
	var fields map[string]json.RawMessage
	if err := json.Unmarshal(payload, &fields); err != nil || fields == nil {
		return BioChange{}, fmt.Errorf("body: expected a JSON object")
	}
	var op string
	_ = json.Unmarshal(fields["op"], &op) // a missing or non-string op stays ""
	switch op {
	case "keep", "clear":
		return BioChange{Kind: op}, nil
	case "set":
		raw, present := fields["value"]
		var value string
		if !present || string(raw) == "null" || json.Unmarshal(raw, &value) != nil {
			return BioChange{}, fmt.Errorf("value: a set operation needs a string value")
		}
		return BioChange{Kind: "set", Value: value}, nil
	default:
		return BioChange{}, fmt.Errorf("op: must be keep, clear, or set")
	}
}

func applyChange(profile Profile, change BioChange) Profile {
	switch change.Kind {
	case "keep":
		return profile
	case "clear":
		return Profile{}
	case "set":
		value := change.Value
		return Profile{Bio: &value}
	default:
		return profile
	}
}

The decoder turns absence into a named decision before the stored profile changes.

See the boundary call siteThe domain receives an operation
TypeScriptPatch boundary
profile.ts · boundary
export function handleProfilePatch(profile: Profile, payload: unknown) {
	const decoded = decodeBioPatch(payload);
	if (!decoded.ok) {
		return { status: 400, body: { error: decoded.field, message: decoded.message } };
	}
	return { status: 200, profile: applyChange(profile, decoded.change) };
}
GoPatch boundary
profile.go · boundary
func handleProfilePatch(profile Profile, payload []byte) (int, Profile, error) {
	change, err := decodeBioPatch(payload)
	if err != nil {
		return 400, profile, err
	}
	return 200, applyChange(profile, change), nil
}

The update function no longer needs to inspect JSON or guess what null meant.

Copy the complete examplesStandard library only

These files are complete and copyable. The browser lab is a focused comparison, not an arbitrary-code REPL.

TypeScriptComplete example
profile.ts
export type Profile = { bio: string | null };

export type BioPatch = { bio?: string | null };

export type BioChange = { kind: 'keep' } | { kind: 'clear' } | { kind: 'set'; value: string };

export type DecodeResult =
	{ ok: true; change: BioChange } | { ok: false; field: string; message: string };

export type ProfilePatch = {
	bio?: string | null;
};

export type ExplicitBioPatch = { op: 'keep' } | { op: 'clear' } | { op: 'set'; value: string };

export function applyNullish(profile: Profile, patch: BioPatch): Profile {
	return { bio: patch.bio ?? profile.bio };
}

export function applyPresence(profile: Profile, patch: BioPatch): Profile {
	if (!Object.hasOwn(patch, 'bio') || patch.bio === undefined) return profile;
	return { bio: patch.bio };
}

function isRecord(value: unknown): value is Record<string, unknown> {
	return typeof value === 'object' && value !== null && !Array.isArray(value);
}

export function decodeBioPatch(value: unknown): DecodeResult {
	if (!isRecord(value)) return { ok: false, field: 'body', message: 'Expected a JSON object.' };
	if (!Object.hasOwn(value, 'bio') || value.bio === undefined) {
		return { ok: true, change: { kind: 'keep' } };
	}
	if (value.bio === null) return { ok: true, change: { kind: 'clear' } };
	if (typeof value.bio === 'string') return { ok: true, change: { kind: 'set', value: value.bio } };
	return { ok: false, field: 'bio', message: 'Bio must be a string or null.' };
}

/** Alternative C: the wire carries the operation itself, such as { "op": "clear" }. */
export function decodeBioOperation(value: unknown): DecodeResult {
	if (!isRecord(value)) return { ok: false, field: 'body', message: 'Expected a JSON object.' };
	switch (value.op) {
		case 'keep':
		case 'clear':
			return { ok: true, change: { kind: value.op } };
		case 'set':
			return typeof value.value === 'string'
				? { ok: true, change: { kind: 'set', value: value.value } }
				: { ok: false, field: 'value', message: 'A set operation needs a string value.' };
		default:
			return { ok: false, field: 'op', message: 'Op must be keep, clear, or set.' };
	}
}

export function applyChange(profile: Profile, change: BioChange): Profile {
	switch (change.kind) {
		case 'keep':
			return profile;
		case 'clear':
			return { bio: null };
		case 'set':
			return { bio: change.value };
	}
}

export function handleProfilePatch(profile: Profile, payload: unknown) {
	const decoded = decodeBioPatch(payload);
	if (!decoded.ok) {
		return { status: 400, body: { error: decoded.field, message: decoded.message } };
	}
	return { status: 200, profile: applyChange(profile, decoded.change) };
}

export function example() {
	return handleProfilePatch({ bio: 'Ships small changes.' }, { bio: null });
}

console.log(example());
GoComplete example
profile.go
package main

import (
	"encoding/json"
	"fmt"
)

// A pointer alone cannot tell a missing JSON key from an explicit null.
type NaiveProfilePatch struct {
	Bio *string `json:"bio"`
}

type BioChange struct {
	Kind  string
	Value string
}

type Profile struct {
	Bio *string
}


func decodeBioPatch(payload []byte) (BioChange, error) {
	var fields map[string]json.RawMessage
	if err := json.Unmarshal(payload, &fields); err != nil || fields == nil {
		return BioChange{}, fmt.Errorf("body: expected a JSON object")
	}
	raw, present := fields["bio"]
	if !present {
		return BioChange{Kind: "keep"}, nil
	}
	if string(raw) == "null" {
		return BioChange{Kind: "clear"}, nil
	}
	var bio string
	if err := json.Unmarshal(raw, &bio); err != nil {
		return BioChange{}, fmt.Errorf("bio: expected a string or null")
	}
	return BioChange{Kind: "set", Value: bio}, nil
}

// decodeBioOperation is alternative C: the wire carries the operation itself,
// such as {"op":"clear"} or {"op":"set","value":"Updated."}.
func decodeBioOperation(payload []byte) (BioChange, error) {
	var fields map[string]json.RawMessage
	if err := json.Unmarshal(payload, &fields); err != nil || fields == nil {
		return BioChange{}, fmt.Errorf("body: expected a JSON object")
	}
	var op string
	_ = json.Unmarshal(fields["op"], &op) // a missing or non-string op stays ""
	switch op {
	case "keep", "clear":
		return BioChange{Kind: op}, nil
	case "set":
		raw, present := fields["value"]
		var value string
		if !present || string(raw) == "null" || json.Unmarshal(raw, &value) != nil {
			return BioChange{}, fmt.Errorf("value: a set operation needs a string value")
		}
		return BioChange{Kind: "set", Value: value}, nil
	default:
		return BioChange{}, fmt.Errorf("op: must be keep, clear, or set")
	}
}

func applyChange(profile Profile, change BioChange) Profile {
	switch change.Kind {
	case "keep":
		return profile
	case "clear":
		return Profile{}
	case "set":
		value := change.Value
		return Profile{Bio: &value}
	default:
		return profile
	}
}


func handleProfilePatch(profile Profile, payload []byte) (int, Profile, error) {
	change, err := decodeBioPatch(payload)
	if err != nil {
		return 400, profile, err
	}
	return 200, applyChange(profile, change), nil
}


func main() {
	status, profile, err := handleProfilePatch(
		Profile{Bio: stringPointer("Ships small changes.")},
		[]byte(`{"bio":null}`),
	)
	fmt.Printf("%d bio-cleared=%t error=%v\n", status, profile.Bio == nil, err)
}

func stringPointer(value string) *string { return &value }

TypeScriptnode --experimental-strip-types profile.ts

Gogo run profile.go

Cross the wire

Preserve only the meanings you need.

A TypeScript form can hold an explicit undefined property, but an API contract cannot rely on that property surviving JSON serialization. A Go pointer can represent a nullable string, but missing and null both decode to nil unless the decoder inspects the raw keys.

That is not a reason to make every payload a tagged union. It is a reason to identify the boundary and choose the smallest representation that preserves the operation.

Build UIs?See where this shows up in your components.

A form reset is a product decision.

“Clear the draft” and “do not change the saved value” may look alike in a form control. Keep the editing state explicit, then map it to the API operation once. The component should not use a translated empty label as its machine-readable meaning.

Make a conditional recommendation

Choose the smallest contract that survives.

If “no value” really has one meaning, a nullable or optional field is enough. If a PATCH must distinguish keep from clear, use presence plus null for a small JSON contract. When several operations, reasons, or independently owned producers need to evolve, decode an explicit operation and accept its extra vocabulary.

One meaning

Collapse the cases.

Use a default when null, missing, and undefined all intentionally do the same thing.

Small JSON patch

Use missing plus null.

Inspect key presence at the edge and keep the rule documented.

Growing command vocabulary

Use an explicit operation.

Pay the extra shape to make new commands and payload rules visible.

Decision practice

A PATCH omits bio in one request and sends bio: null in another.

The first should keep the current bio. The second should clear it. Which contract preserves both meanings?

Leave yourself a useful note

Keep the meaning of absence.

“The bio is optional” leaves the next person guessing. Record what omission, null, empty string, and an in-memory undefined mean at each boundary.

Why
The update must distinguish keep from clear.
What
Missing keeps, null clears, and a string sets.
Constraint
JSON removes undefined properties and the client can deploy independently.
Fallback
A value that is neither missing, null, nor a string is rejected at the decoder, so the stored bio never changes on a guess.
Reconsider when
The API gains more operations, payload data, or a new serialization boundary.

A decision note to adapt to your own API. Nothing here is saved to an account.

Explore more concepts & practices →