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.
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.
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.
Treat nullish as one case
Use a default such as patch.bio ?? current. It is concise when null and
missing both mean keep.
Presence plus null
Check whether the key exists, then let null mean clear and a string mean set.
Explicit operation
Send keep, clear, or set as a tagged command, and
decode it with decodeBioOperation.
| Strategy | Missing | Null | Cost / limit |
|---|---|---|---|
| Nullish default | Keep | Keep | Simple, but no clear operation. |
| Presence + null | Keep | Clear | Compact; JSON key presence must be inspected. |
| Explicit operation | Needs a keep operation | Needs a decoder policy | More bytes and vocabulary; easiest to extend deliberately. |
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.
What does an empty-looking value mean?
Keep the profile update fixed. Change the value or the interpretation.
{ 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.
Hold the meaning steady. Change the language.
The lab runs TypeScript. These panes show how each language preserves the same update contract.
Name the representations
export type ProfilePatch = {
bio?: string | null;
};
export type ExplicitBioPatch = { op: 'keep' } | { op: 'clear' } | { op: 'set'; value: string }; // 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
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 };
}
} 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
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) };
} 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.
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());
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
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.
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.
Collapse the cases.
Use a default when null, missing, and undefined all intentionally do the same thing.
Use missing plus null.
Inspect key presence at the edge and keep the rule documented.
Use an explicit operation.
Pay the extra shape to make new commands and payload rules visible.
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?
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 →