TypeScript results: distinguish absence, invalid input, and dependency failure
Parse unknown data and use discriminated unions to preserve operational meaning through callers.
On this page
Key takeaways
- Returning null for every failure destroys information callers need to respond correctly.
- Runtime validation establishes shape; a result union preserves meaning.
First principles
A missing record, malformed data, and an unavailable dependency are not interchangeable outcomes. A caller may respond to a missing record with a not-found screen, malformed data with an internal diagnostic, and temporary unavailability with a retry option. If a helper returns null for all three, the caller cannot recover those distinctions. The type looks simple because information was discarded, not because the domain became simpler. Explicit result states preserve the facts needed by later decisions.
TypeScript can describe a value's shape, but incoming bytes do not acquire that shape through a type annotation. JSON parsing returns a value whose runtime structure must still be checked. Treat it as unknown and validate it with a runtime schema. Successful validation establishes the structural properties your code relies on. It does not establish permissions, freshness, or business relationships between the record and the current user. Those remain separate decisions even if the validated value is strongly typed.
A discriminated union represents outcomes using a shared literal field such as kind. Within each branch, the compiler narrows to the fields valid for that state. This avoids combinations such as an error flag alongside a success payload with no clear precedence. Exhaustive handling makes new states visible to callers during compilation. It is especially useful when several layers must preserve whether a failure is permanent, transient, or caused by invalid dependency data.
Runnable example
Save this as result-demo.ts in this repository and run it with node --import tsx result-demo.ts. It uses the already installed Zod package and performs no I/O. The demonstration accepts a fixture representing a dependency response; a real adapter would translate transport failures into a separate unavailable state before calling the parser.
import assert from 'node:assert/strict';
import { z } from 'zod';
const recordSchema = z.object({
id: z.string().min(1),
count: z.number().int().nonnegative(),
});
type RecordValue = z.infer<typeof recordSchema>;
type Result =
| { kind: 'ok'; value: RecordValue }
| { kind: 'missing' }
| { kind: 'invalid'; reason: string }
| { kind: 'unavailable'; retryable: boolean };
function decode(status: number, body: unknown): Result {
if (status === 404) return { kind: 'missing' };
if (status !== 200) return { kind: 'unavailable', retryable: status === 503 };
const parsed = recordSchema.safeParse(body);
return parsed.success
? { kind: 'ok', value: parsed.data }
: { kind: 'invalid', reason: 'dependency response failed validation' };
}
function unreachable(value: never): never {
throw new Error('unhandled result');
}
function present(result: Result): string {
switch (result.kind) {
case 'ok': return `count=${result.value.count}`;
case 'missing': return 'not found';
case 'invalid': return 'invalid upstream data';
case 'unavailable': return result.retryable ? 'try later' : 'service error';
default: return unreachable(result);
}
}
assert.equal(present(decode(200, { id: 'sample', count: 0 })), 'count=0');
assert.equal(present(decode(200, { id: 'sample', count: '0' })), 'invalid upstream data');
assert.equal(present(decode(404, null)), 'not found');
assert.equal(present(decode(503, null)), 'try later');
console.log('four outcomes remain distinguishable');
The schema accepts numeric zero and rejects the string zero. That choice is intentional: coercing every value can turn malformed producer output into apparently valid data. When coercion is appropriate, document it at the boundary and test ambiguous cases such as empty strings. The example also uses a deliberately narrow status mapping. A real adapter should distinguish authentication errors, throttling, and protocol violations rather than labeling every non-success status as retryable.
Contextual failure case
A dashboard displays zero open incidents whenever its data helper returns null. During a dependency outage, the helper catches every exception and returns null, so operators see a reassuring empty dashboard precisely when visibility is lost. The interface is not merely inaccurate; it drives the wrong operational decision. Returning unavailable lets the page show missing evidence instead of falsely asserting that nothing happened. Use incident timelines to reason about observations without confusing absent telemetry with a confirmed healthy state.
Another service casts parsed JSON directly to its interface. A deployment changes a count field from number to string, and downstream arithmetic concatenates values. The compiler cannot detect this because the cast asserted a fact that was never checked. Runtime validation moves failure to the boundary, while the union ensures that boundary failure does not disappear into a generic empty value. Both parts are needed: a validator whose errors are swallowed still loses meaning.
Verification
The example prints four outcomes remain distinguishable. Add a negative count, missing ID, and fractional count, and verify all become invalid. Add a new union member for permission denied, then run TypeScript checking before updating present. The exhaustive default should cause a compile error because the remaining value is no longer never. Note that tsx runs TypeScript but does not provide the same static checking as the TypeScript compiler.
Inspect synthetic fixture variants with payload tools. Keep validation diagnostics internal and sanitized; returning the entire invalid response may expose upstream secrets. Test both successful rendering and every non-success branch. A caller that converts invalid and unavailable back into missing would undo the result model's benefit even though all its types compile.
Exercise
Add a throttled outcome containing a validated retry delay. Make the presentation layer preserve it, and make a retry policy accept only unavailable with retryable true or the throttled state. Bound the delay and total attempt budget. Explain why successful structural validation cannot tell you whether an operation is safe to retry, and identify where an idempotency contract would enter your design.
Safety and caveats
Do not catch programming defects and relabel them as expected missing data. Keep error causes for restricted diagnostics without exposing them to end users. Exhaustiveness protects typed call sites, not arbitrary runtime objects that bypass your parser. A result union should name domain-relevant distinctions rather than grow one state for every low-level exception. Evolve shared results deliberately because adding a state changes callers' obligations, which is exactly the useful pressure an exhaustive type is meant to create.