Typing Promises That Reject with Specific Error Types in TypeScript
Introduction
Promises are a bedrock primitive for asynchronous programming in JavaScript and TypeScript. We often focus on typing what a Promise resolves to, but what about the errors a Promise can reject with? In medium to large codebases, being explicit about rejection types reduces debugging time, improves autocomplete and refactoring safety, and enables better runtime error handling. This tutorial teaches intermediate developers systematic ways to express Promise rejection types in TypeScript, practical patterns for real-world APIs, and how to evolve designs safely.
By the end of this article you will be able to:
- Reason about whether to type Promise rejections in your API surface.
- Use discriminated unions, custom error classes, and type predicates to narrow rejection types.
- Design APIs that encode both success and failure shapes in types to avoid uncaught unknowns.
- Migrate existing code that uses any or type assertions toward safer patterns.
We will walk through examples with type-safe wrappers, generic utilities, and Node.js interoperability. Along the way, you will see how related TypeScript techniques such as typing Promises that resolve with different types, custom type guards, and exact object typing help build robust async code. Links to deeper reference material are included so you can go further on related topics.
Background & Context
In TypeScript, a Promise
We will explore multiple approaches: annotating APIs with typed wrappers, using discriminated unions to represent result outcomes, leveraging custom Error subclasses, and runtime type guards to narrow unknown rejection values. If you use JavaScript with JSDoc, there are compatible patterns too. You may also find it useful to review typing patterns for Promise resolution to complement the strategies here, such as in our guide on Typing Promises That Resolve with Different Types.
Key Takeaways
- Promise rejection types are not part of Promise
by default; you can encode them with alternatives. - Prefer explicit result unions like Result<T, E> or discriminated unions for predictable control flow.
- Use custom Error subclasses for interoperability with existing JS libraries and Node APIs.
- Use type predicates and runtime checks to safely narrow unknown errors before handling.
- Avoid leaking any and unchecked assertions; prefer gradual migrations backed by tests.
Prerequisites & Setup
This guide assumes you are comfortable with TypeScript generics, union types, and the basics of Promise-based async code. You should have Node.js and npm installed if you want to run examples locally. Recommended tooling:
- TypeScript 4.5+ (or latest stable)
- ts-node or a small build step to execute TypeScript examples
- An editor with TypeScript intellisense
If you work in a JavaScript codebase, you can apply similar ideas using JSDoc; see our article on Using JSDoc for Type Checking JavaScript Files (@typedef, @type, etc.) for migration tips.
Why Promise Rejection Types Matter
When a Promise rejects with many possible shapes, downstream code ends up making unsafe assumptions. A catch handler that assumes Error with message property can crash if rejection is a plain object or a number. Explicit typing helps document expectations and enables the compiler to guide correct handling. Even when you cannot change library APIs, wrapping third-party calls with adapters that map to your typed shapes provides safer boundaries.
Example: a network fetch that returns either data or validation errors. Without typing rejections, consumers may miss structured error data. With typed rejections, developers can write exhaustive handling paths.
Approaches Overview
There are several patterns to express rejection types:
- Use a Result type or discriminated union that encodes success and failure in the return type.
- Use custom Error subclasses annotated in function signatures via overloads or complementary types.
- Use wrappers that convert unknown rejections into a typed envelope value.
- Enforce runtime checks with type predicates when working with unknown errors.
We will cover each approach with practical examples and migration paths.
1. Using a Result<T, E> Type
One of the most explicit ways to capture both success and error shapes is to return a Result value instead of throwing or rejecting. This pattern mirrors languages like Rust and functional JS libraries.
Example Result definition:
type Ok<T> = { ok: true; value: T }
type Err<E> = { ok: false; error: E }
export type Result<T, E> = Ok<T> | Err<E>
async function fetchUser(id: string): Promise<Result<User, ValidationError>> {
const res = await fetch('/user/' + id)
if (res.status === 400) {
const err = await res.json() as ValidationError
return { ok: false, error: err }
}
const data = await res.json() as User
return { ok: true, value: data }
}
// usage
const result = await fetchUser('123')
if (!result.ok) {
// result.error is typed as ValidationError
} else {
// result.value is typed as User
}Advantages:
- Type system captures both outcomes.
- No untyped rejections; control flow is clear.
Drawbacks:
- Requires callers to remember to check the ok flag.
- Slightly different ergonomics compared to exceptions.
This approach pairs nicely with API design where domain errors are expected and routine.
2. Typed Error Classes and Adapters
When throwing is preferred, define custom Error subclasses and surface them in your API documentation and types. TypeScript cannot force Promise
Example:
class ApiError extends Error {
constructor(public code: number, message: string) {
super(message)
this.name = 'ApiError'
}
}
async function getData(): Promise<Data> {
const res = await fetch('/data')
if (!res.ok) throw new ApiError(res.status, 'failed')
return res.json() as Data
}To make the rejection type more discoverable, add a companion type alias or an exported documentation type:
export type GetDataRejection = ApiError
You can also create a helper that asserts an error is an ApiError at runtime using a type predicate. See the section on type predicates below. When dealing with Node.js EventEmitters and error-first callbacks, look at our piece on Typing Event Emitters in TypeScript: Node.js and Custom Implementations for interop patterns.
3. Using Type Predicates and Custom Type Guards
Often rejection values are unknown at compile time. Type predicates are the bridge between runtime checks and compile-time narrowing. Use them to constrain unknown to a more specific error type before accessing properties.
Example type guard:
function isApiError(e: unknown): e is ApiError {
return typeof e === 'object' && e !== null && 'code' in e && 'message' in e
}
try {
await getData()
} catch (e) {
if (isApiError(e)) {
// e is ApiError here
console.log(e.code)
} else {
// fallback for unknown rejections
}
}This approach is especially useful when working with libraries that may reject with plain objects or strings. For more on writing robust type guards, refer to our article on Using Type Predicates for Custom Type Guards.
4. Discriminated Unions and 'as const' for Structured Errors
When you have a finite set of error kinds, discriminated unions let you pattern-match safely. Use 'as const' to retain literal values for the discriminant.
Example:
type NetworkError = { type: 'network'; details: string }
type ValidationError = { type: 'validation'; fields: Record<string, string> }
type AppError = NetworkError | ValidationError
async function callApi(): Promise<Result<Data, AppError>> {
// on error return { ok: false, error: { type: 'validation', fields: { name: 'required' } } }
}
const err: AppError = { type: 'network', details: 'timed out' } as constUsing 'as const' helps TypeScript infer literal discriminant types. For more on literal type inference and 'as const', see Using as const for Literal Type Inference in TypeScript.
5. Wrapping Third-Party Promises into Typed Envelopes
When using libraries that throw or reject with unknown shapes, write adapters that map library errors into your typed domain errors. This reduces the surface area of unknown rejections.
Example adapter:
async function safeFetch<T>(): Promise<Result<T, AppError>> {
try {
const value = await thirdPartyFetch<T>()
return { ok: true, value }
} catch (e) {
if (isNetworkErrorLike(e)) return { ok: false, error: { type: 'network', details: String(e) } }
return { ok: false, error: { type: 'validation', fields: { general: 'unknown' } } }
}
}This centralizes error normalization, making the rest of the codebase easier to type and reason about. Applying exact typing to your error shapes helps avoid accidental extra properties; see Typing Objects with Exact Properties in TypeScript for patterns.
6. Overloading and Documentation Patterns
You can use function overloads or complementary exported types to document expected rejection types. Although overloads do not change the runtime, they enable clearer developer experience in editors.
Example overload pattern:
export type FetchRejection = ApiError | ValidationError
async function fetchThing(id: string): Promise<Thing>
async function fetchThing(id: string): Promise<Thing> {
// implementation that may throw ApiError or ValidationError
return await realFetch(id)
}The overload doesn't change the compiled JS, but the exported FetchRejection type communicates intent. If you prefer returning discriminated unions instead, this is another option that ties into the topics covered in our article on Typing Functions with Multiple Return Types (Union Types Revisited).
7. Handling Unknown Rejections Safely
Even with careful typing, some rejections will be unknown at runtime. Use narrowing and safe access patterns to prevent crashes.
Examples:
try {
await something()
} catch (e) {
if (typeof e === 'object' && e && 'message' in e) {
// e has message but still unknown exact type
console.log((e as { message?: unknown }).message)
} else {
console.log('Unexpected rejection', e)
}
}Consider a global error normalization step where uncaught rejections are mapped to a known shape for logging and monitoring.
8. Interoperability with Node.js Callbacks and EventEmitters
Node's callback and EventEmitter patterns often surface errors per event or callback arguments. When bridging them to Promises, normalize the errors into typed forms.
Example promisify with typed rejection:
function promisifyWithTypedError<T, E>(fn: (cb: (err: unknown, res?: T) => void) => void): Promise<T> {
return new Promise((resolve, reject) => {
fn((err, res) => {
if (err) return reject(normalizeError(err))
resolve(res as T)
})
})
}Here normalizeError converts Node errors into domain types. For EventEmitter patterns, evaluate typed wrappers rather than handling raw events across your codebase; our guide on Typing Event Emitters in TypeScript: Node.js and Custom Implementations shows strategies for typed interop.
9. Migration Strategies for Legacy Code
If a codebase is littered with any or assertion-based error handling, migrate gradually. Start by wrapping critical boundaries and introducing typed adapters. Add unit tests that assert normalization behavior, then slowly refactor callers to rely on typed APIs. Avoid big-bang replacements because error shapes ripple across modules.
Be mindful of security implications when you cast unknown values to trusted error shapes. Our article on Security Implications of Using any and Type Assertions in TypeScript explains pitfalls and migration tactics.
Advanced Techniques
When you need greater type expressiveness, consider these expert options:
- Conditional types and mapped utilities to extract possible error types from a suite of functions and derive an aggregated rejection union.
- Tagged result builders that produce exhaustive pattern matching helpers so TypeScript can guarantee all error kinds are handled.
- Leveraging discriminated unions with exhaustive switch statements to get compiler errors when a new error variant is added.
- Using runtime schema validators like Zod or io-ts to both validate and derive TypeScript types for rejection payloads.
Example of deriving an error union from different API functions:
type ApiErrors = FetchError | AuthError | ValidationError
function handle(err: ApiErrors) {
switch (err.type) {
case 'fetch': return // handle
case 'auth': return
case 'validation': return
}
}Runtime validation libraries can be helpful when your error shapes come from external sources, and they can generate types that align with your runtime checks.
Best Practices & Common Pitfalls
Dos:
- Do prefer explicit result types for predictable error handling when domain errors are routine.
- Do centralize normalization of third-party rejections to keep code readable and safe.
- Do write type predicates and runtime validators for unsafe boundaries.
- Do export error types and document rejection behavior clearly in public APIs.
Don'ts:
- Don’t rely on Promise
to carry rejection types implicitly; the type system needs your help. - Don’t overuse any or unchecked assertions to silence the compiler; this hides bugs and security risks.
- Don’t forget to handle the possibility of non-Error rejections such as plain objects or strings.
Troubleshooting tips:
- If an error still escapes as unknown, add a logging wrapper that records the raw rejection to make normalization simpler.
- Use unit tests to validate that adapters convert third-party errors as expected.
- When migrating, keep old behavior while adding typed wrappers and flip callers incrementally.
For guidance on typing arrays, rest params, and other nearby topics that affect API ergonomics, see related articles like Typing Arrays of Mixed Types (Union Types Revisited) and Typing Functions with Variable Number of Arguments (Rest Parameters Revisited).
Real-World Applications
- HTTP clients: Normalize HTTP error responses into typed domain errors with status and validation payloads.
- Database layers: Map DB driver errors to typed errors such as NotFound, UniqueConstraintViolation, and QueryError.
- RPC systems: Encode error codes and metadata into discriminated unions so clients handle conditionally.
- Background job processors: Use Result types to signal retryable vs non-retryable failures explicitly.
These patterns improve observability, make retries safer, and reduce runtime type mismatches.
Conclusion & Next Steps
Typing Promise rejections is about creating predictable boundaries and documenting failure modes. Choose the pattern that matches your team culture: Result types for explicit control flows, typed Error classes for conventional exceptions, and wrappers for gradual migration. Combine these with type predicates and runtime validators to make rejection handling robust.
Next steps:
- Pick one module with frequent rejections and apply a typed adapter.
- Add a few unit tests that assert normalization behavior.
- Explore complementary TypeScript topics linked throughout this article to build more robust APIs.
Enhanced FAQ Section
Q: Can TypeScript express rejection types directly on Promise
A: Not directly. The Promise generic only models success value T. TypeScript does not include a second generic parameter for rejection, so you must encode error information in other ways such as a Result type, exported rejection types, or wrapper utilities. Many of the techniques in this article aim to make rejection types explicit even though Promise
Q: Should I always return Result<T, E> instead of throwing?
A: Not always. Result types are great when failures are expected, and the caller should handle them as part of normal flow. Throwing is more ergonomic for exceptional conditions or when you want traditional try/catch. Choose what matches your API semantics, but be consistent so callers know whether to inspect a result or rely on try/catch.
Q: How do I handle libraries that reject with non-Error values?
A: Wrap calls to such libraries with adapters that normalize rejection shapes. Use type predicates and validators to coerce or map values into your canonical error types. Centralizing normalization reduces repetitive checks across your codebase.
Q: Are custom Error subclasses enough to type rejections?
A: They help, and they are familiar to JavaScript developers. Custom Error subclasses provide useful runtime type checks via instanceof and custom properties. But they do not change Promise
Q: What about using any or asserting error types?
A: Avoid relying on any and unchecked assertions for rejections. They bypass compile-time safety and can mask security issues. If you must use assertions temporarily during migration, pair them with tests and plan to replace them with proper validators. See our discussion on Security Implications of Using any and Type Assertions in TypeScript for guidance.
Q: How can I validate error payloads from network responses at runtime?
A: Use schema validators like Zod, io-ts, or Yup and define schemas for error payloads. These libraries can both parse at runtime and provide static TypeScript types to keep runtime and compile-time in sync. For literal discriminants, use 'as const' so TypeScript keeps literal types, as explained in Using as const for Literal Type Inference in TypeScript.
Q: How do typed rejections affect ergonomics for API consumers?
A: If you adopt Result types, consumers must check the ok flag or use helper combinators, but the benefit is deterministic handling and composability. If you keep throwing semantics, provide exported error types and type guards so consumers can safely narrow catches. Balance ergonomics and safety based on your team needs.
Q: Can I infer rejection types across many functions automatically?
A: You can create helper types and utilities to aggregate potential error types, but automation is only as accurate as the types you annotate. Conditional and mapped types can help derive union types from exported error aliases, but this requires discipline in annotating functions and errors.
Q: Should I write tests for error normalization?
A: Yes. Tests that assert adapters convert third-party errors into your domain types are extremely valuable during migration and refactoring. They catch changes in upstream libraries and protect downstream consumers.
Q: Are there related TypeScript topics I should study to improve async typing?
A: Yes. Understanding how to type functions with multiple return types, arrays of mixed types, generator functions, and context-aware functions will improve your API design. See related articles like Typing Functions with Multiple Return Types (Union Types Revisited), Typing Arrays of Mixed Types (Union Types Revisited), and Typing Generator Functions and Iterators in TypeScript — An In-Depth Guide to broaden your typing toolbox.
If you want a practical next task, try adapting one network call in your codebase to return Result<T, E> and write tests for both success and the normalized error paths. This hands-on step will illuminate the ergonomics tradeoffs discussed here and help you choose the right pattern for your project.
