Error Handling & Safety
In JavaScript, any value can be thrown using the throw statement—an Error instance, a string, a number, or even null. Consequently, inside a catch (err) block, the type of err is completely unpredictable at runtime. Prior to TypeScript 4.4, catch variables were implicitly typed as any, creating dangerous situations where developers called err.message on non-Error values, causing secondary runtime crashes inside error handlers.
In modern TypeScript, error handling is strictly enforced. By enabling "useUnknownInCatchVariables": true (included in "strict": true), catch variables are typed as unknown.
In this lesson, we explore type-safe error handling, custom error hierarchies, assertion functions (asserts), exhaustive switch verification, and functional Result<T, E> pattern architectures.
┌────────────────────────────────────────────────────────────┐
│ Type-Safe Error Flow │
│ │
│ try { │
│ await executeOperation(); │
│ } catch (err: unknown) { │
│ // Type Narrowing Required │
│ if (err instanceof ApiHttpError) { │
│ console.log(err.statusCode, err.details); │
│ } else if (err instanceof Error) { │
│ console.log(err.message); │
│ } else { │
│ console.log('Unknown error:', String(err)); │
│ } │
│ } │
└────────────────────────────────────────────────────────────┘
unknown vs any in Catch Clauses
When "useUnknownInCatchVariables": true is active, TypeScript forces you to narrow the error object before accessing any properties on it:
function parseUserJson(jsonString: string): UserProfile | null {
try {
return JSON.parse(jsonString) as UserProfile;
} catch (error: unknown) {
// Direct access triggers compile error:
// Error: 'error' is of type 'unknown'.
// console.log(error.message);
// Safe error message extraction:
const message = error instanceof Error ? error.message : "Failed to parse JSON string";
console.error(`[PARSE ERROR]: ${message}`);
return null;
}
}
Creating Custom Error Classes
In enterprise applications, creating domain-specific error classes allows your application to distinguish between network timeouts, authorization failures, database constraints, and validation errors:
export class DomainError extends Error {
public readonly timestamp: Date;
constructor(message: string, public readonly code: string) {
super(message);
this.name = this.constructor.name;
this.timestamp = new Date();
// Maintain proper prototype chain across downlevel compilation
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class ValidationError extends DomainError {
constructor(message: string, public readonly invalidFields: string[]) {
super(message, "VALIDATION_FAILED");
}
}
export class NotFoundError extends DomainError {
constructor(resourceName: string, id: string) {
super(`${resourceName} with ID '${id}' was not found.`, "RESOURCE_NOT_FOUND");
}
}
With custom error classes, consumer code can handle specific failures gracefully using instanceof:
function handleGlobalError(err: unknown) {
if (err instanceof ValidationError) {
console.warn(`Validation failed on fields: ${err.invalidFields.join(", ")}`);
} else if (err instanceof NotFoundError) {
console.warn(`Resource missing: ${err.message}`);
} else if (err instanceof Error) {
console.error(`Unexpected system error: ${err.message}`);
} else {
console.error("Unknown non-error object thrown:", err);
}
}
Assertion Functions (asserts condition)
TypeScript supports Assertion Functions—special functions that throw an error if a condition is false. Once an assertion function is invoked, TypeScript narrows the type of the checked variable for the remainder of the containing scope:
// Asserts that a condition is true
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(`[ASSERTION FAILURE]: ${message}`);
}
}
// Asserts that a value is defined (neither null nor undefined)
function assertIsDefined<T>(value: T, name: string): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error(`Expected '${name}' to be defined, but received ${value}`);
}
}
function processOrder(userId?: string | null) {
// 'userId' has type 'string | null | undefined'
assertIsDefined(userId, "userId");
// After assertion, TypeScript knows 'userId' is strictly 'string'!
console.log(`Processing order for user: ${userId.toUpperCase()}`);
}
Exhaustive Checking with never
When dealing with discriminated unions representing state or action types, exhaustive checking guarantees that every possible union branch is handled. If someone adds a new variant to the union in the future, the compiler raises an error at the unhandled never assignment:
type PaymentMethod = "credit_card" | "paypal" | "apple_pay" | "crypto";
function processPaymentFlow(method: PaymentMethod, amount: number): string {
switch (method) {
case "credit_card":
return `Charged $${amount} to Credit Card`;
case "paypal":
return `Redirected to PayPal for $${amount}`;
case "apple_pay":
return `Authorized Apple Pay for $${amount}`;
case "crypto":
return `Generated Crypto invoice for $${amount}`;
default: {
// If a new PaymentMethod (e.g., 'bank_transfer') is added without a case above,
// TypeScript reports a compile error right here!
const exhaustiveCheck: never = method;
throw new Error(`Unhandled payment method: ${exhaustiveCheck}`);
}
}
}
The Functional Result<T, E> Pattern (No Thrown Exceptions)
Throwing exceptions bypasses TypeScript's type system because function return types do not track thrown exceptions. Many modern TypeScript codebases adopt the functional Result Pattern (popularized by Rust) to make error states explicit in function signatures:
export type Result<TData, TError = Error> =
| { success: true; value: TData }
| { success: false; error: TError };
export function ok<T>(value: T): Result<T, never> {
return { success: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { success: false, error };
}
// Function signature explicitly advertises potential failure modes
function divideNumbers(numerator: number, denominator: number): Result<number, string> {
if (denominator === 0) {
return err("Cannot divide by zero.");
}
return ok(numerator / denominator);
}
// Consumer must handle both branches safely:
const outcome = divideNumbers(100, 4);
if (outcome.success) {
console.log(`Calculated result: ${outcome.value}`);
} else {
console.error(`Division failed: ${outcome.error}`);
}
Summary
- Catch variables are typed as
unknownunder"strict": true, mandating runtime type checks. - Custom error classes extending
Errorallow granular error categorization and type-safeinstanceofbranching. - Assertion functions (
asserts value is T) narrow types for the rest of a function's scope by throwing upon failure. - Exhaustive switch checks using
neverprevent unhandled union variants when data models evolve. - The functional
Result<T, E>pattern makes errors explicit in function return types, eliminating surprise runtime exceptions.
Best Practices
- Never Type Catch Variables as
any: Treat all caught exceptions asunknownand verify withinstanceof Error. - Always Call
super(message)in Custom Errors: Ensure custom error subclasses invokesuper(message)and restore the prototype chain. - Use Assertion Functions for Invariants: Validate preconditions and non-null states at function entry points using
assertIsDefined(). - Prefer
Result<T, E>for Expected Domain Failures: Use thrown exceptions only for fatal, unexpected system crashes; useResult<T, E>for expected business logic validation errors.