Default values feel safe. They prevent crashes, keep builds green, make TypeScript happy.
They’re also where bugs go to hide.
The localStorage bug
TypeScript’s own docs show why defaults backfire:
function function getVolume(): string | 0.5
getVolume() { const const volume: string | null
volume = var localStorage: Storage
localStorage.Storage.getItem(key: string): string | null
getItem('volume')
return const volume: string | null
volume || 0.5}Looks defensive. Missing volume? Fall back to 50%.
Except when the stored value is valid but falsy, such as an empty string or a value that becomes 0 once parsed, this returns 0.5.
The fix:
function function getVolume(): string | 0.5
getVolume() { const const volume: string | null
volume = var localStorage: Storage
localStorage.Storage.getItem(key: string): string | null
getItem('volume')
return const volume: string | null
volume ?? 0.5}Now valid falsy values are preserved. Problem solved?
Not quite. We fixed a || bug and quietly introduced a domain bug.
localStorage always returns strings, which makes this kind of bug easy to miss once parsing is involved.
The real question
Before writing ?? 0.5, ask:
Is “no stored volume” the same as “50% volume”?
What if the user cleared their preferences? What if localStorage is disabled? What if there’s a save bug?
All of these now produce “50%.”
The default doesn’t prevent an error. It masks different failure modes as identical.
Defaults hide assumptions
Every default makes a claim about your domain:
const const quantity: number
quantity = const item: { quantity?: number;}
item.quantity?: number | undefined
quantity ?? 0Claims: “missing quantity” = “zero items”
const const userId: string
userId = const user: { id?: string;}
user.id?: string | undefined
id ?? ''Claims: “missing user ID” = “empty string is valid”
const const price: number
price = const product: { price?: number;}
product.price?: number | undefined
price ?? 0Claims: “unknown price” = “free”
These aren’t defensive. They’re assertions that fail silently.
Example 1: The arithmetic bug
const const itemsPerBox: number
itemsPerBox = const item: { quantity?: number; boxSize?: number;}
item.quantity?: number | undefined
quantity && const item: { quantity?: number; boxSize?: number;}
item.boxSize?: number | undefined
boxSize ? const item: { quantity?: number; boxSize?: number;}
item.quantity?: number
quantity / const item: { quantity?: number; boxSize?: number;}
item.boxSize?: number
boxSize : 0Returns 0 for:
- Zero items ordered
- Missing quantity
- Missing box size
- Invalid box size
Four completely different scenarios. One number.
Feed it to analytics: zero demand for out-of-stock products. Ship it to inventory: sold-out items marked available.
Example 2: The invisible order
function function createOrderLine(item: OrderItem): { productId: string; quantity: number; storeId: number;}
createOrderLine(item: OrderItem
item: interface OrderItem
OrderItem) { return { productId: string
productId: item: OrderItem
item.OrderItem.productId?: string | undefined
productId ?? '', quantity: number
quantity: item: OrderItem
item.OrderItem.quantity?: number | undefined
quantity ?? 0, storeId: number
storeId: item: OrderItem
item.OrderItem.storeId?: number | undefined
storeId ?? 0 }}This creates:
- Orders with no product
- Orders for zero items
- Orders assigned to store
0(which might not exist)
Downstream systems process invalid data without crashing.
Example 3: The API garbage collector
const const payload: { orderId: string; userId: string;}
payload = { orderId: string
orderId: const order: Order
order.Order.id?: number | undefined
id?.Number.toString(radix?: number): string
Returns a string representation of an object.
toString() ?? '', userId: string
userId: const user: User
user.User.id?: string | undefined
id ?? ''}
await const api: { post: (url: string, data: unknown) => Promise<void>;}
api.post: (url: string, data: unknown) => Promise<void>
post('/orders', const payload: { orderId: string; userId: string;}
payload)If the API expects real IDs, empty strings don’t make requests safer. They make failures impossible to debug.
A rejected request tells you something broke. A silently accepted one tells you nothing.
Until customers report phantom orders weeks later.
Parse, don’t default
In Parse, don’t validate, Alexis King argues validation should produce typed evidence of correctness.
Defaults do the opposite:
- Accept invalid input
- Pretend it’s valid
- Throw away all evidence
Compare:
function function processOrder(items: OrderItem[]): void
processOrder(items: OrderItem[]
items: interface OrderItem
OrderItem[]) { const const quantity: number
quantity = items: OrderItem[]
items[0]?.OrderItem.quantity?: number | undefined
quantity ?? 0Warning:}With:
function function parseOrderItem(item: OrderItem): { ok: false; error: string;} | { ok: true; value: { quantity: number; };}
parseOrderItem(item: OrderItem
item: interface OrderItem
OrderItem) { if (typeof item: OrderItem
item.OrderItem.quantity?: number | undefined
quantity !== 'number' || item: OrderItem
item.OrderItem.quantity?: number
quantity <= 0) { return function err<string>(error: string): Result<never, string>
err('quantity must be positive') }
return function ok<{ quantity: number;}>(value: { quantity: number;}): Result<{ quantity: number;}, never>
ok({ quantity: number
quantity: item: OrderItem
item.OrderItem.quantity?: number
quantity })}First approach: accept any OrderItem, invent data when missing.
Second approach: refuse to proceed until data is proven valid.
The decision framework
Before adding a default:
-
Is this value truly optional in the domain?
- Can a product with no price be sold?
- Is an order with no items still an order?
-
Would the default mask a broken assumption?
- Will
0be indistinguishable from legitimate zero? Will''hide a missing ID?
- Will
-
Do I want silent failure or loud failure?
- Catch this in development or debug it in production?
If the value shouldn’t exist, don’t pretend it does.
When defaults are correct
Sometimes they’re legitimate:
// User preference with sensible defaultconst const theme: string
theme = const settings: { theme?: string;}
settings.theme?: string | undefined
theme ?? 'light'
// Optional config with fallbackconst const timeout: number
timeout = const config: { timeout?: number;}
config.timeout?: number | undefined
timeout ?? 5000
// Truly nullable dataconst const middleName: string | null
middleName = const person: { middleName?: string | null;}
person.middleName?: string | null | undefined
middleName ?? nullThe difference: these are truly optional in the domain.
“No theme preference” means “use light theme."
"No timeout” means “use 5 seconds."
"No middle name” means null.
Fail at the boundary
Catch bad data where it enters your system with a schema validation library like Zod:
import { import z
z } from 'zod'
const const orderSchema: z.ZodObject<{ productId: z.ZodString; quantity: z.ZodNumber; storeId: z.ZodNumber;}, z.core.$strip>
orderSchema = import z
z.function object<{ productId: z.ZodString; quantity: z.ZodNumber; storeId: z.ZodNumber;}>(shape?: { productId: z.ZodString; quantity: z.ZodNumber; storeId: z.ZodNumber;} | undefined, params?: string | { error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined; message?: string | undefined | undefined;} | undefined): z.ZodObject<{ productId: z.ZodString; quantity: z.ZodNumber; storeId: z.ZodNumber;}, z.core.$strip>
object({ productId: z.ZodString
productId: import z
z.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)
string()._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodString
min(1), quantity: z.ZodNumber
quantity: import z
z.function number(params?: string | z.core.$ZodNumberParams): z.ZodNumber
number()._ZodNumber<$ZodNumberInternals<number>>.positive(params?: string | z.core.$ZodCheckGreaterThanParams): z.ZodNumber
positive(), storeId: z.ZodNumber
storeId: import z
z.function number(params?: string | z.core.$ZodNumberParams): z.ZodNumber
number()._ZodNumber<$ZodNumberInternals<number>>.positive(params?: string | z.core.$ZodCheckGreaterThanParams): z.ZodNumber
positive()})
declare const const app: { post: (path: string, handler: (req: any, res: any) => void) => void;}
app: { post: (path: string, handler: (req: any, res: any) => void) => void
post: (path: string
path: string, handler: (req: any, res: any) => void
handler: (req: any
req: any, res: any
res: any) => void) => void}
const app: { post: (path: string, handler: (req: any, res: any) => void) => void;}
app.post: (path: string, handler: (req: any, res: any) => void) => void
post('/orders', (req: any
req, res: any
res) => { const const result: z.ZodSafeParseResult<{ productId: string; quantity: number; storeId: number;}>
result = const orderSchema: z.ZodObject<{ productId: z.ZodString; quantity: z.ZodNumber; storeId: z.ZodNumber;}, z.core.$strip>
orderSchema.ZodType<any, any, $ZodObjectInternals<{ productId: ZodString; quantity: ZodNumber; storeId: ZodNumber; }, $strip>>.safeParse(data: unknown, params?: z.core.ParseContext<z.core.$ZodIssue>): z.ZodSafeParseResult<{ productId: string; quantity: number; storeId: number;}>
safeParse(req: any
req.any
body)
if (!const result: z.ZodSafeParseResult<{ productId: string; quantity: number; storeId: number;}>
result.success: boolean
success) { return res: any
res.any
status(400).any
json({ errors: z.ZodError<{ productId: string; quantity: number; storeId: number;}>
errors: const result: z.ZodSafeParseError<{ productId: string; quantity: number; storeId: number;}>
result.error: z.ZodError<{ productId: string; quantity: number; storeId: number;}>
error }) }
function (local function) createOrder(data: z.infer<typeof orderSchema>): void
createOrder(const result: z.ZodSafeParseSuccess<{ productId: string; quantity: number; storeId: number;}>
result.data)data: { productId: string; quantity: number; storeId: number;}
declare function function (local function) createOrder(data: z.infer<typeof orderSchema>): void
createOrder(data: { productId: string; quantity: number; storeId: number;}
data: import z
z.type infer<T> = T extends { _zod: { output: any; };} ? T["_zod"]["output"] : unknownexport infer
infer<typeof const orderSchema: z.ZodObject<{ productId: z.ZodString; quantity: z.ZodNumber; storeId: z.ZodNumber;}, z.core.$strip>
orderSchema>): voidOnce data is parsed, never check it again.
Once data is defaulted, never trust it again.
Hierarchy of approaches
- Parse at the boundary (best) - Fail before any processing happens
- Assert on entry (acceptable) - Fail immediately with clear error
- Default silently (worst) - Hide the problem and corrupt data
If you can’t do #1, do #2. Never do #3.
If you can’t parse, at least assert
Sometimes parsing at the boundary isn’t practical. Legacy code, third-party types, tight deadlines.
In those cases, you can use assertion functions instead of defaults:
function function assertPositiveNumber(value: number | undefined): asserts value is number
assertPositiveNumber( value: number | undefined
value: number | undefined): asserts value: number | undefined
value is number { if (typeof value: number | undefined
value !== 'number' || value: number
value <= 0) { throw new var Error: ErrorConstructornew (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('value must be a positive number') }}
interface interface OrderItem
OrderItem { OrderItem.quantity?: number
quantity?: number OrderItem.boxSize: number
boxSize: number}
function function processOrder(item: OrderItem): void
processOrder(item: OrderItem
item: interface OrderItem
OrderItem) { function assertPositiveNumber(value: number | undefined): asserts value is number
assertPositiveNumber(item: OrderItem
item.OrderItem.quantity?: number | undefined
quantity)
const const itemsPerBox: number
itemsPerBox = item: OrderItem
item.quantity / item: OrderItem
item.boxSizeOrderItem.quantity?: number
}The proof lives in control flow, not in a returned value.
Better than:
function function processOrder(item: OrderItem): void
processOrder(item: OrderItem
item: interface OrderItem
OrderItem) { const const quantity: number
quantity = item: OrderItem
item.OrderItem.quantity?: number | undefined
quantity ?? 0 const const itemsPerBox: number
itemsPerBox = const quantity: number
quantity / item: OrderItem
item.OrderItem.boxSize: number
boxSizeWarning:}Assertions crash immediately with context. Defaults silently propagate bad data.
This isn’t as good as parsing, but it’s infinitely better than defaulting. At least crashes tell you something broke.
The cost of silence
The most expensive bugs aren’t crashes.
They’re weeks of incorrect inventory counts.
Analytics dashboards showing phantom revenue.
Customers charged $0 for orders that should have failed.
Defaults don’t reduce risk. They turn loud failures into quiet mistakes.
If you’re adding ?? defaultValue, ask: am I parsing or pretending?
Parsing validates and preserves evidence.
Pretending validates nothing and hides everything.
Loud is better. 🤘