Typing Error Objects in TypeScript: Custom and Built-in Errors
Introduction
Errors are unavoidable in real-world software. In JavaScript and TypeScript, the Error object (and subtypes like TypeError, RangeError, and custom classes) is the main way to communicate failure. But when you move from dynamic JS to TypeScript, naively treating an error as any or string erodes type safety: you lose structured error data, autocompletion, and the ability to write reliable error-handling code. This tutorial teaches intermediate developers how to design, type, validate, and consume both built-in and custom Error objects in TypeScript.
You will learn how to: design custom Error classes with typed payloads, type-check Error values coming from untyped code or external sources, represent error unions for exhaustive handling, use discriminated unions and literal inference, write robust runtime guards with type predicates, and integrate errors with async flows and configuration. Each section includes practical examples, step-by-step patterns, and troubleshooting tips so you can apply these techniques in production code immediately.
By the end of this guide you'll be comfortable creating typed error hierarchies, verifying untrusted errors safely at runtime, and choosing the right trade-offs between ergonomics and strictness. We'll also cover advanced techniques like augmenting Error prototypes, preserving stack traces, and minimizing the use of any and type assertions to avoid security pitfalls.
Background & Context
JavaScript's Error is flexible: you can throw primitives, objects, or custom instances. TypeScript's static types, however, are only as good as the types you declare and enforce. When errors cross boundaries (third-party libs, network responses, worker threads), runtime shapes can diverge from your type declarations. Typing errors well improves DX, helps maintainers handle failures deterministically, and enables safer API contracts (for example, when APIs return different error shapes conditionally).
Errors are special: they have identity (instances), behavior (stack traces), and often carry structured metadata (codes, contexts, HTTP status). Treating errors as plain unions or as any leads to brittle code. This guide balances static typing with pragmatic runtime checks and links to related TypeScript patterns such as exact property typing and type predicates for robust validation. For more on avoiding excess properties, see our guide on Typing Objects with Exact Properties in TypeScript.
Key Takeaways
- How to type built-in Error subclasses and preserve stack traces
- Patterns for typed custom Error classes with payloads and discriminants
- Runtime validation techniques using type predicates for untrusted errors
- Strategies for representing error unions and discriminated errors
- How to type errors crossing async boundaries (Promise rejections)
- Pitfalls with any, type assertions and how to avoid them
For guidance on writing type guards, see Using Type Predicates for Custom Type Guards.
Prerequisites & Setup
This tutorial assumes intermediate TypeScript knowledge (generics, union types, conditional types) and Node/TS project setup. You'll need:
- Node.js 16+ and a TypeScript project (tsconfig.json)
- TypeScript 4.5+ recommended for better type inference
- Optional: ts-node or a build setup for running examples
If you work in mixed JS/TS codebases, consider adding JSDoc annotations to JavaScript files for better editor checks; see our primer on Using JSDoc for Type Checking JavaScript Files for setup tips.
Main Tutorial Sections
1) Why typing errors matters (and what can go wrong)
Errors are often consumed by generic catch blocks: catch (err) { ... }. In TypeScript, catch variables default to unknown (since TS 4.0) which encourages safe handling. Problems begin when code assumes err.message or err.code exists — incorrectly typed errors lead to runtime crashes. Typing errors correctly forces you to explicitly assert or guard expected properties.
Actionable step: always treat caught values as unknown and narrow them with type predicates (examples follow). This pattern prevents accidental property access and reduces runtime exceptions.
2) Typing built-in Error subclasses
TypeScript provides the Error type, but many built-in errors (TypeError, SyntaxError) are available as classes. When you throw them, type them explicitly:
function validate(input: unknown) {
if (typeof input !== 'string') throw new TypeError('Input must be a string');
}
try {
validate(1 as unknown);
} catch (e: unknown) {
if (e instanceof TypeError) console.error('Type error:', e.message);
}Tip: always use catch (e: unknown) and then e instanceof Error or instanceof TypeError so you get typed access to message and stack. Note that cross-realm errors (e.g., workers or iframes) may fail instanceof checks.
3) Designing custom Error classes with typed payloads
Custom errors often need additional metadata (code, status, details). Create a base typed Error and extend it:
type ErrorPayload = { code: string } & Record<string, any>;
class AppError<P extends ErrorPayload = { code: string }> extends Error {
readonly code: P['code'];
readonly payload?: P;
constructor(message: string, payload?: P) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
this.name = this.constructor.name;
this.payload = payload;
this.code = (payload && payload.code) || 'ERR_APP';
}
}
class NotFoundError extends AppError<{ code: 'NOT_FOUND'; resource: string }> {}Actionable: parameterize payloads with generics so callers get precise types. Keep constructors minimal to preserve stack traces.
4) Using discriminated unions for error handling
A discriminated union gives powerful exhaustive handling. Use a literal type or code field:
type AuthError =
| { type: 'InvalidCredentials'; message: string }
| { type: 'AccountLocked'; message: string; unlockAt: string };
function handleAuth(err: AuthError) {
switch (err.type) {
case 'InvalidCredentials':
// handle
break;
case 'AccountLocked':
// access err.unlockAt safely
break;
}
}To infer literal types reliably from constants, use as const so TypeScript preserves literal types; see Using as const for Literal Type Inference in TypeScript for details.
5) Runtime validation of untrusted errors with type predicates
When errors come from JSON APIs, workers, or cross-language boundaries, they might just be plain objects. Use type predicates to narrow unknowns safely:
function isAppError(obj: unknown): obj is { message: string; code?: string } {
return (
typeof obj === 'object' &&
obj !== null &&
'message' in obj &&
typeof (obj as any).message === 'string'
);
}
try {
// some untyped library throws
} catch (e: unknown) {
if (isAppError(e)) {
console.error('Handled error code:', (e as any).code);
} else {
console.error('Unknown error', e);
}
}For more on writing robust guards, refer to Using Type Predicates for Custom Type Guards.
6) Errors in async flows and Promise rejections
Promises can reject with any value. To type handlers, declare the Promise result type and treat rejections as unknown:
async function fetchData(): Promise<string> {
// may throw or reject with string/Error/object
throw { message: 'network', code: 'NET_ERR' };
}
try {
await fetchData();
} catch (err: unknown) {
if (err instanceof Error) console.error(err.message);
else if (typeof err === 'object') console.error('object error');
}When API endpoints return typed error payloads, combine runtime validation with the patterns from Typing Promises That Resolve with Different Types to describe possible outcomes and keep handlers exhaustive.
7) Preserving stack traces and prototype chains
When extending Error in TypeScript, you should set the prototype and avoid altering stack traces. Use Object.setPrototypeOf(this, new.target.prototype) in constructors. Also avoid serializing and re-throwing errors if you want to preserve stack info—wrap or rethrow carefully:
class ValidationError extends Error {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}Actionable step: when wrapping errors, consider attaching the original error as cause (supported in Node 16+ and newer Error options) instead of creating a new error without context.
8) Serializing, transporting, and reconstructing errors safely
Serializing errors (e.g., over HTTP or worker messages) requires both shape and runtime validation. Define a stable transport shape:
const errorShape = {
message: 'string',
code: 'string',
stack: 'string?'
} as const;
// On receiver, validate and reconstruct
function reconstructError(payload: Record<string, unknown>) {
if (typeof payload.message === 'string') {
const err = new Error(payload.message);
// optionally attach code
(err as any).code = typeof payload.code === 'string' ? payload.code : undefined;
return err;
}
return new Error('Unknown remote error');
}See also the section on typing configuration and environment errors for safely handling transported error metadata; related patterns are described in Typing Environment Variables and Configuration in TypeScript.
9) Integrating typed errors with configuration and runtime options
Errors often depend on configuration (feature flags, environment). Type your config strictly and validate at startup so errors remain predictable. For example, an error-handler that logs to a provider should be typed to accept only known provider options:
type LoggerConfig = { provider: 'console' | 'sentry'; sentryDsn?: string };
function initLogger(cfg: LoggerConfig) { /* ... */ }If config is loaded from environment, validate it and produce typed configuration objects: see Typing Configuration Objects in TypeScript: Interfaces vs Type Aliases — An Intermediate Guide for strategies.
10) Testing, mocking, and error-heavy scenarios
When writing tests for error flows, create factory helpers for consistent error instances. Use typed factories so test assertions are accurate:
function makeDbError(resource = 'user') {
return new AppError('Not found', { code: 'NOT_FOUND', resource });
}
// in tests
expect(() => someFn()).toThrowError(AppError);When mocking third-party libraries that throw untyped values, use runtime guards in your test harness or convert them to typed errors before assertions. For practical patterns when typing hooks and async fetchers, see our case study on Practical Case Study: Typing a Data Fetching Hook.
Advanced Techniques
Beyond the core patterns, advanced techniques help when building libraries or complex systems:
- Use branded types and nominal typing to prevent accidental mixing of error payloads: add a unique symbol property that only your code sets.
- Create error factories with typed builders so payload shapes are enforced at creation time.
- Use conditional types to derive union types from a map of error descriptors and provide exhaustive switch guards with helper functions.
- Leverage TypeScript declaration merging to augment third-party Error interfaces (use sparingly and with care).
When building fluent APIs or libraries that chain methods and throw errors, ensure your types convey possible thrown errors. Patterns from Typing Libraries That Use Method Chaining in TypeScript can guide library API design. Also, be cautious with excessive type assertions — see Security Implications of Using any and Type Assertions in TypeScript for pitfalls and safer alternatives.
Best Practices & Common Pitfalls
Do:
- Treat catch variables as unknown and narrow them explicitly
- Prefer typed Error subclasses with minimal constructors to preserve stack
- Use discriminated unions for structured error handling
- Validate untrusted errors before accessing properties
- Keep error payloads small and serializable
Don't:
- Use any or assert shapes without validation when errors come from external sources
- Rely solely on instanceof across realms (workers/iframes)
- Strip stack traces inadvertently when rethrowing; use cause to wrap where available
Common pitfalls and fixes:
- Problem: cross-realm errors failing instanceof. Fix: use duck-typing guards (check for message string) or use a transport
typefield. - Problem: untyped rejection payloads. Fix: treat rejects as unknown and validate with predicates or zod-like schemas.
For ideas on typing optional object parameters in functions that build or handle errors, consult Typing Functions with Optional Object Parameters in TypeScript — Deep Dive.
Real-World Applications
- API clients: represent API error responses as discriminated unions and validate on response parsing; handlers can then switch exhaustively and show tailored UI messages.
- CLI tools: typed errors can include exit codes and structured hints to guide users; factories ensure consistent diagnostics.
- Libraries: provide typed error classes so consumers can programmatically respond (e.g., retry on network error codes).
When building stateful systems like stores, typed error payloads help maintain predictable state transitions—see the approach demonstrated in Practical Case Study: Typing a State Management Module for inspiration on integrating error typing into state flows.
Conclusion & Next Steps
Typing Error objects in TypeScript requires a blend of static typing and runtime validation. Use typed custom classes for internal invariants, discriminated unions for structured handling, and type predicates (runtime guards) for untrusted inputs. Avoid any and unchecked assertions; prefer validated reconstruction when transporting errors.
Next steps: adopt guard-based patterns across your codebase, add error factories, and integrate validation libraries for runtime schema checks. For more on related typing techniques, explore our guides on type predicates, configuration typing, and promise typing linked throughout this article.
Enhanced FAQ
Q1: Should I extend Error for all custom errors or use plain objects? A1: Prefer extending Error when you want identity (instanceof checks), preserved stack traces, and to interoperate with tools that expect Error instances (sentry, logging libs). For simple payloads or API responses, discriminated plain objects are sufficient and often easier to serialize. If you extend Error, make sure to set the prototype correctly with Object.setPrototypeOf(this, new.target.prototype) to preserve instanceof.
Q2: How do I type a catch block in TypeScript? A2: Use catch (e: unknown). unknown forces you to narrow before property access. Then use instanceof, type predicates, or typeof checks. Example:
try {
// ...
} catch (e: unknown) {
if (e instanceof Error) console.error(e.message);
else if (typeof e === 'object' && e !== null) { /* runtime checks */ }
}Q3: How can I handle errors coming from third-party libraries that throw plain objects or strings? A3: Treat those as unknown and validate. Create a small normalization layer that converts strings or plain objects into your typed AppError instances. This centralizes handling and minimizes assertion spread.
Q4: What is the recommended way to serialize errors for transport (HTTP, workers)? A4: Define a stable transport schema (e.g., { type, message, code, meta }) and only include serializable fields. On the receiver, validate the shape and reconstruct into a local Error instance or a discriminated object. Avoid shipping full stack traces to clients in production for security reasons.
Q5: Can I rely on instanceof checks across worker boundaries? A5: No. instanceof checks are sensitive to execution realms (different global contexts). For cross-realm scenarios, prefer discriminants (type/code fields) or duck-typing checks like verifying message string and code field existence.
Q6: How can I avoid overusing any or type assertions when typing errors? A6: Centralize casts in a single small normalization function that validates shapes. Use type predicates and, when appropriate, runtime schema validators (zod, io-ts) to convert unknown values into typed objects.
Q7: Should I include extra metadata (like userId or requestId) in Error payloads? A7: Include minimal, non-sensitive metadata that helps debugging (requestId, code). Avoid putting sensitive user data into errors, especially when errors may be logged or sent to external systems. Use structured logging to attach contextual fields rather than embedding them in error messages.
Q8: How do discriminated unions compare to class-based errors?
A8: Discriminated unions (plain objects with a type field) are lightweight, easy to serialize, and work well for API designs. Class-based errors are better for runtime identity and interoperability with Error-aware tooling. Choose based on whether you need instance identity or portability.
Q9: How should I type Promises that may reject with multiple error shapes?
A9: Promises don't encode rejection types in TypeScript's type system (Promise
Q10: Any tips for integrating error typing into larger systems? A10: Adopt consistent error factories, centralize normalization and logging, and enforce validation at boundaries (network, worker, 3rd-party). Where possible, make errors part of your typed API contract (e.g., functions returning Promise<Result<T, E>> or using discriminated unions). When designing libraries or fluent APIs, consult patterns for method chaining and typed architectures such as our guide on Typing Libraries That Use Method Chaining in TypeScript to ensure consumers can handle errors predictably.
Additional resources:
- For exact object typing to avoid accidental extra properties on error payloads, see Typing Objects with Exact Properties in TypeScript.
- To learn more about optional object parameters when building error constructors or handlers, see Typing Functions with Optional Object Parameters in TypeScript — Deep Dive.
- If your error scenarios intersect with configuration or env variables, check Typing Environment Variables and Configuration in TypeScript.
- For security considerations when using any and assertions in error handling code, read Security Implications of Using any and Type Assertions in TypeScript.
This guide should give you a solid foundation to type and manage errors robustly in TypeScript across small apps and large systems alike. Start by auditing your catch blocks and centralizing normalization — small changes yield big safety wins.
