Stop Writing Defensive Null Checks: Use the Rust-Inspired Result Pattern in TypeScript
In standard JavaScript and TypeScript, functions throw exceptions implicitly. When a function signature looks like:
function parseConfig(raw: string): AppConfig { ... }
The type system tells you nothing about failure modes. If parsing fails, it throws at runtime. The caller has no idea it needs a try/catch until production logs blow up with uncaught exceptions.
The Problem with Exceptions for Expected Errors
Exceptions should be reserved for exceptional, unrecoverable system failures (e.g., out-of-memory, network hardware drop). Domain failures—like validation errors, failed lookups, or bad payloads—are expected states. Treating them as thrown exceptions destroys type safety and complicates control flow.
The Fix: Explicit Result<T, E>
Borrowing from Rust’s Result<T, E>, we model success and failure as explicit return values instead of hidden throws.
// 1. Define the algebraic Result type
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
How to Implement It in Real Code:
Instead of throwing inside your business logic, wrap your outcomes:
interface ValidationError {
field: string;
reason: string;
}
function parsePort(input: string): Result<number, ValidationError> {
const port = Number(input);
if (isNaN(port) || port <= 0 || port > 65535) {
return Err({ field: "PORT", reason: "Must be a valid integer between 1 and 65535" });
}
return Ok(port);
}
How It Enforces Safe Consuming:
TypeScript’s discriminated union forces the developer to check result.ok before accessing result.value. Attempting to read result.value when ok: false is rejected at compile time:
TypeScript
const result = parsePort(process.env.APP_PORT ?? "");
if (!result.ok) {
// TypeScript knows 'result.error' is safely typed here
console.error(`Config failure on ${result.error.field}: ${result.error.reason}`);
process.exit(1);
}
// Compiler guarantees 'result.value' exists and is a number
const port = result.value;
server.listen(port);
Why This Upgrades Your Architecture:
Self-Documenting Signatures: Callers instantly see every failure mode in their IDE without reading source code.
Zero Uncaught Explosions: Errors are treated as normal control flow, making multi-step pipelines easily composable.
Deterministic Testing: You test predictable data structures rather than asserting whether a method threw an exception.
Discussion Question
Do you rely on explicit union types/monadic patterns like Result in your TypeScript backend, or do you still prefer native try/catch and custom exception classes? What tradeoffs have you seen in large codebases?
CTA (Join Developers & Coding)
Ready to sharpen your software craft with modern architecture patterns, typed systems, and clean code principles? Join the Developers & Coding community to collaborate on production patterns with peers worldwide.
In standard JavaScript and TypeScript, functions throw exceptions implicitly. When a function signature looks like:
function parseConfig(raw: string): AppConfig { ... }
The type system tells you nothing about failure modes. If parsing fails, it throws at runtime. The caller has no idea it needs a try/catch until production logs blow up with uncaught exceptions.
The Problem with Exceptions for Expected Errors
Exceptions should be reserved for exceptional, unrecoverable system failures (e.g., out-of-memory, network hardware drop). Domain failures—like validation errors, failed lookups, or bad payloads—are expected states. Treating them as thrown exceptions destroys type safety and complicates control flow.
The Fix: Explicit Result<T, E>
Borrowing from Rust’s Result<T, E>, we model success and failure as explicit return values instead of hidden throws.
// 1. Define the algebraic Result type
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
How to Implement It in Real Code:
Instead of throwing inside your business logic, wrap your outcomes:
interface ValidationError {
field: string;
reason: string;
}
function parsePort(input: string): Result<number, ValidationError> {
const port = Number(input);
if (isNaN(port) || port <= 0 || port > 65535) {
return Err({ field: "PORT", reason: "Must be a valid integer between 1 and 65535" });
}
return Ok(port);
}
How It Enforces Safe Consuming:
TypeScript’s discriminated union forces the developer to check result.ok before accessing result.value. Attempting to read result.value when ok: false is rejected at compile time:
TypeScript
const result = parsePort(process.env.APP_PORT ?? "");
if (!result.ok) {
// TypeScript knows 'result.error' is safely typed here
console.error(`Config failure on ${result.error.field}: ${result.error.reason}`);
process.exit(1);
}
// Compiler guarantees 'result.value' exists and is a number
const port = result.value;
server.listen(port);
Why This Upgrades Your Architecture:
Self-Documenting Signatures: Callers instantly see every failure mode in their IDE without reading source code.
Zero Uncaught Explosions: Errors are treated as normal control flow, making multi-step pipelines easily composable.
Deterministic Testing: You test predictable data structures rather than asserting whether a method threw an exception.
Discussion Question
Do you rely on explicit union types/monadic patterns like Result in your TypeScript backend, or do you still prefer native try/catch and custom exception classes? What tradeoffs have you seen in large codebases?
CTA (Join Developers & Coding)
Ready to sharpen your software craft with modern architecture patterns, typed systems, and clean code principles? Join the Developers & Coding community to collaborate on production patterns with peers worldwide.
Stop Writing Defensive Null Checks: Use the Rust-Inspired Result Pattern in TypeScript
In standard JavaScript and TypeScript, functions throw exceptions implicitly. When a function signature looks like:
function parseConfig(raw: string): AppConfig { ... }
The type system tells you nothing about failure modes. If parsing fails, it throws at runtime. The caller has no idea it needs a try/catch until production logs blow up with uncaught exceptions.
The Problem with Exceptions for Expected Errors
Exceptions should be reserved for exceptional, unrecoverable system failures (e.g., out-of-memory, network hardware drop). Domain failures—like validation errors, failed lookups, or bad payloads—are expected states. Treating them as thrown exceptions destroys type safety and complicates control flow.
The Fix: Explicit Result<T, E>
Borrowing from Rust’s Result<T, E>, we model success and failure as explicit return values instead of hidden throws.
// 1. Define the algebraic Result type
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
How to Implement It in Real Code:
Instead of throwing inside your business logic, wrap your outcomes:
interface ValidationError {
field: string;
reason: string;
}
function parsePort(input: string): Result<number, ValidationError> {
const port = Number(input);
if (isNaN(port) || port <= 0 || port > 65535) {
return Err({ field: "PORT", reason: "Must be a valid integer between 1 and 65535" });
}
return Ok(port);
}
How It Enforces Safe Consuming:
TypeScript’s discriminated union forces the developer to check result.ok before accessing result.value. Attempting to read result.value when ok: false is rejected at compile time:
TypeScript
const result = parsePort(process.env.APP_PORT ?? "");
if (!result.ok) {
// TypeScript knows 'result.error' is safely typed here
console.error(`Config failure on ${result.error.field}: ${result.error.reason}`);
process.exit(1);
}
// Compiler guarantees 'result.value' exists and is a number
const port = result.value;
server.listen(port);
Why This Upgrades Your Architecture:
Self-Documenting Signatures: Callers instantly see every failure mode in their IDE without reading source code.
Zero Uncaught Explosions: Errors are treated as normal control flow, making multi-step pipelines easily composable.
Deterministic Testing: You test predictable data structures rather than asserting whether a method threw an exception.
Discussion Question
Do you rely on explicit union types/monadic patterns like Result in your TypeScript backend, or do you still prefer native try/catch and custom exception classes? What tradeoffs have you seen in large codebases?
CTA (Join Developers & Coding)
Ready to sharpen your software craft with modern architecture patterns, typed systems, and clean code principles? Join the Developers & Coding community to collaborate on production patterns with peers worldwide.