Back to blog

Default Values Are Silent Failures

3 min read

Cover image for Default Values Are Silent Failures
Image crafted by robots

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.

Note

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
?? 0

Claims: “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
?? 0

Claims: “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
: 0

Returns 0 for:

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:

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.

@paramradix Specifies a radix for converting numeric values to strings. This value is only used for numbers.

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:

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
?? 0
Warning: quantity is a number, but is it real?
}

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:

  1. 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?
  2. Would the default mask a broken assumption?

    • Will 0 be indistinguishable from legitimate zero? Will '' hide a missing ID?
  3. 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 default
const
const theme: string
theme
=
const settings: {
theme?: string;
}
settings
.
theme?: string | undefined
theme
?? 'light'
// Optional config with fallback
const
const timeout: number
timeout
=
const config: {
timeout?: number;
}
config
.
timeout?: number | undefined
timeout
?? 5000
// Truly nullable data
const
const middleName: string | null
middleName
=
const person: {
middleName?: string | null;
}
person
.
middleName?: string | null | undefined
middleName
?? null

The 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"] : unknown
export infer
infer
<typeof
const orderSchema: z.ZodObject<{
productId: z.ZodString;
quantity: z.ZodNumber;
storeId: z.ZodNumber;
}, z.core.$strip>
orderSchema
>): void

Once data is parsed, never check it again.

Once data is defaulted, never trust it again.

Hierarchy of approaches

  1. Parse at the boundary (best) - Fail before any processing happens
  2. Assert on entry (acceptable) - Fail immediately with clear error
  3. 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: ErrorConstructor
new (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
.boxSize
OrderItem.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
boxSize
Warning: itemsPerBox might be 0 due to defaulted quantity
}

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

Let's Discuss

Questions or feedback? Send me an email.

Last updated on

Back to blog