Typing Promises That Resolve with Different Types
Introduction
Promises are ubiquitous in modern JavaScript and TypeScript code: network requests, file I/O, timers, and any async work frequently return a Promise. But when a Promise can resolve to different types depending on inputs, runtime conditions, or external data, typing that Promise correctly becomes challenging. Incorrect or overly permissive types can hide bugs, make downstream code awkward, or force unsafe casts.
In this article you'll learn how to type Promises that can resolve to multiple different types in TypeScript in a robust, maintainable way. We'll cover union types, discriminated unions, generics, overloads, conditional types, tuple inference with Promise.all, Promise.race typings, and runtime narrowing using type predicates. Throughout, you'll see practical examples, step-by-step patterns, and troubleshooting tips for both library authors and application developers.
Specifically, by the end of this tutorial you will be able to:
- Choose the right pattern (union vs discriminant vs generic) for your API surface.
- Write safe typings for Promise combinators like Promise.all and Promise.race.
- Narrow resolved values with custom type guards and keep runtime checks aligned with compile-time types.
- Avoid common pitfalls (excessive any, unsafe assertions) and use compiler flags and tooling to catch issues early.
This guide assumes an intermediate understanding of TypeScript (generics, conditional types, basic union narrowing). It includes runnable code snippets and references to deeper resources so you can apply these patterns in production systems.
Background & Context
Why does typing Promises that resolve to different types matter? Two reasons: correctness and developer experience. Correct types let the compiler check your assumptions about what code will receive once a Promise resolves. Better DX means less runtime debugging, clear intent in APIs, and fewer casts.
TypeScript provides several ways to express variation in resolved values: simple union types, discriminated unions, generics that vary by input, and overloads. Each approach trades off ergonomics, inference, and safety. When combining asynchronous patterns with advanced TypeScript features like conditional types or variadic tuple inference, you can create types that precisely reflect runtime behavior while remaining ergonomic.
Compiler flags and tooling also affect how safe your typings are. If you want stricter guarantees around indexing or unknowns, consider flags like noUncheckedIndexedAccess and other advanced compiler flags. Build tooling can influence type-check speed and workflow; for faster iteration, tools such as esbuild or swc are often used alongside TypeScript.
Key Takeaways
- Use discriminated unions when resolved types share a common discriminant for safe narrowing.
- Prefer generics when the resolved type depends on input parameters.
- Avoid returning overly broad types like any; prefer unknown + narrowing or precise union types.
- Use tuple-aware typings for Promise.all to preserve element order and types.
- Type guards and type predicates are essential to narrow Promise results at runtime safely.
- Configure compiler flags and choose tooling to improve checks and speed.
Prerequisites & Setup
What you'll need to follow the examples in this article:
- Node.js (14+ recommended) and npm or yarn.
- TypeScript 4.5+ (many typing improvements for tuples and inference). Install with npm install -D typescript.
- A code editor with TypeScript support (VS Code recommended).
- Optionally, build tools like esbuild or swc for fast iteration.
Create a simple tsconfig.json with strict: true to get the most benefit from TypeScript's checks. If you are migrating a codebase, consider the guidance in Advanced TypeScript Compiler Flags and Their Impact for safe, incremental changes.
Main Tutorial Sections
1) Simple unions: Promise<T1 | T2>
A quick, straightforward way to type a Promise that sometimes resolves with different shapes is to use a union type.
Example:
type Success = { kind: 'success'; value: number };
type NotFound = { kind: 'not_found'; reason: string };
function fetchSomething(): Promise<Success | NotFound> {
return fetch('/somewhere').then(async res => {
if (res.status === 404) return { kind: 'not_found', reason: 'missing' };
const data = await res.json();
return { kind: 'success', value: data.count };
});
}
fetchSomething().then(result => {
if (result.kind === 'success') console.log(result.value);
else console.log(result.reason);
});When you use unions, code that consumes the result must narrow the union. Discriminated unions (above, using kind) make narrowing easy and safe.
2) Discriminated unions for safer narrowing
Discriminated unions give you a small, constant key (discriminant) to switch on at runtime. This is the most common pattern when multiple outcomes are known and mutually exclusive.
type Result =
| { status: 'ok'; payload: string }
| { status: 'error'; error: Error };
async function doWork(): Promise<Result> {
// runtime chooses shape
}
const res = await doWork();
switch (res.status) {
case 'ok': console.log(res.payload); break;
case 'error': console.error(res.error); break;
}If you need to perform more advanced runtime tests, you can lean on custom type guards (see the section on type predicates below for patterns and examples). For library authors, discriminants improve ergonomics for consumers.
3) Generics: when resolved type depends on input
If the Promise's resolved type depends on an input parameter, generics are the right tool. This keeps the API strongly typed and inferred for callers.
function fetchByType<T extends 'json' | 'text'>(url: string, type: T): Promise<T extends 'json' ? object : string> {
return fetch(url).then(res => type === 'json' ? res.json() : res.text()) as any;
}
const value = await fetchByType('/api', 'json');
// value is inferred as objectTo avoid as any, do narrow casts or overloads (next section). For complex conditional returns, you can combine generics with conditional types to provide precise resolution types.
4) Overloads vs conditional generics
Overloads are useful when a small number of input shapes map to specific outputs and you want clear editor hints.
function read(path: string, encoding: 'utf8'): Promise<string>;
function read(path: string, encoding: 'json'): Promise<object>;
function read(path: string, encoding: 'utf8' | 'json'): Promise<string | object> {
// implementation
}
const a = await read('data', 'json'); // inferred as objectOverloads are explicit and often easier to document but can be verbose. Conditional generics are more compact when you have a direct relationship between input type and output type. Use overloads for public APIs where readability matters.
5) Promise.all, tuples, and preserving element types
When awaiting multiple Promises together, preserving the exact types and element positions is crucial. Modern TypeScript can infer tuple types which keeps things precise.
const p1 = Promise.resolve(1);
const p2 = Promise.resolve('two');
const result = await Promise.all([p1, p2]);
// result is (string | number)[] without tuple inference in older TS
// To preserve tuples and types: ensure literals or typed constants
const tupleResult = await Promise.all([p1, p2] as const);
// tupleResult inferred as readonly [number, string]If you rely on indexing into the tuple later, enabling safer indexing with noUncheckedIndexedAccess can help catch undefined access issues. Also prefer destructuring to avoid index errors:
const [n, s] = tupleResult;
6) Typing Promise.race and Promise.any
Promise.race and Promise.any introduce additional complexity: the resulting type is typically the union of possible resolved values. For Promise.race, the resolved type is the union of each input Promise's resolved types; for Promise.any it is the union of fulfilled values.
const a = Promise.resolve({ kind: 'a' as const });
const b = Promise.resolve({ kind: 'b' as const });
const raced = await Promise.race([a, b]);
// raced: { kind: 'a' } | { kind: 'b' }To write robust code, treat the result as a union and narrow it using discriminants or type guards. If you need to map which Promise won, wrap values with tags before passing them into race.
7) Using type predicates (custom type guards) to narrow async results
When your Promise resolves to a wide type (like unknown or unioned shapes), custom type guards implemented with type predicates improve safety and readability. For a general guide on writing type predicates, check our piece on Using Type Predicates for Custom Type Guards.
Example:
type User = { id: string; name: string };
function isUser(x: any): x is User {
return x && typeof x.id === 'string' && typeof x.name === 'string';
}
const res = await fetch('/user').then(r => r.json() as unknown);
if (isUser(res)) {
// res typed as User here
console.log(res.name);
} else {
throw new Error('Unexpected response');
}Type guards let you start with safer types such as unknown instead of any, reducing accidental misuse. If you're writing a library that exposes runtime validation, consider integrating runtime schemas (see the section below on environment/config typing patterns).
8) Handling unknown, any, and runtime validation
One common anti-pattern is returning Promiseas T without validation. This reduces compile-time guarantees and introduces possible runtime crashes. Prefer unknown and perform explicit checks or use runtime validators.
If you need to expose validated configuration or environment-driven values that require async initialization, look at patterns used for typed runtime configuration in Typing Environment Variables and Configuration in TypeScript.
Example pattern with unknown and a validator:
async function fetchJsonValidated<T>(url: string, validator: (x: unknown) => x is T): Promise<T> {
const data = await fetch(url).then(r => r.json() as unknown);
if (validator(data)) return data;
throw new Error('Validation failed');
}Use libraries like zod or io-ts for schema-based validation, or keep minimal validators for critical paths. This avoids the security and correctness problems discussed in Security Implications of Using any and Type Assertions in TypeScript.
9) Practical case: typing an async data-fetching hook
When building a reusable data-fetching hook in React or similar systems, the resolved type can vary depending on the request and parser. A practical, typed approach is to accept a generic that describes the resolved type and to provide optional runtime validation. For a deeper example and walk-through, consult our Practical Case Study: Typing a Data Fetching Hook.
Simple hook skeleton:
function useAsync<T>(fn: () => Promise<T>) {
const [data, setData] = useState<T | null>(null);
useEffect(() => {
let mounted = true;
fn().then(v => { if (mounted) setData(v); });
return () => { mounted = false; };
}, [fn]);
return data;
}If fn sometimes returns different shapes based on parameters, expose that via generics or discriminated union types. Combine this with type guards for safe render-time checks. This pattern keeps your hook flexible and typesafe.
Advanced Techniques
Once you've mastered the basic patterns, the next level is to combine TypeScript features for even more precise type expression. Conditional types let you transform input types into output types dynamically; mapped and infer will help you express relationships across tuples and objects.
For example, you can type a function that accepts a mapping of loaders and returns a Promise resolving to a mapping of results:
type Loaders = { [K: string]: () => Promise<any> };
async function loadAll<L extends Loaders>(loaders: L): Promise<{ [K in keyof L]: Awaited<ReturnType<L[K]>> }> {
const entries = await Promise.all(Object.keys(loaders).map(k => loaders[k]().then(v => [k, v] as const)));
return Object.fromEntries(entries) as any;
}Using Awaited, conditional types, and variadic tuple inference can give you ergonomics similar to TS's built-in Promise utilities. If you work in large codebases, tune compiler flags and build strategy for best feedback loops—consider the recommendations in Advanced TypeScript Compiler Flags and Their Impact and build-time tooling like esbuild or swc.
Performance tip: avoid overly complex types in hot inner loops where type-checking slows down IDE responsiveness. Keep public API types precise and internal helper types simpler when necessary.
Best Practices & Common Pitfalls
Dos:
- Prefer precise union or discriminated union types over any.
- Use generics where the resolved type depends on inputs.
- Validate external data at runtime and type-guard it to unknown → T.
- Use Promise.all with tuple inference to preserve types.
Don'ts:
- Don’t use
as anyor unchecked assertions for external data; see Security Implications of Using any and Type Assertions in TypeScript for security risks. - Don’t rely on implicit any or weak inference—enable strict compiler options and consider flags such as noUncheckedIndexedAccess for safer indexing (Safer Indexing in TypeScript with noUncheckedIndexedAccess).
- Avoid overcomplicated conditional types where a simple discriminated union would be clearer.
Troubleshooting tips:
- If inference fails, add explicit generics or overloads to guide callers.
- Use small helper types and isolate complex conditional logic into named types for readability.
- Write unit tests asserting type inference when developing libraries—TS has patterns for typing tests that catch regressions.
Real-World Applications
Here are practical places you’ll apply these patterns:
- HTTP clients that may return a success payload or an error shape (use discriminated unions).
- Feature flags or config loaders that expose different typed data depending on environment variables—combine with runtime validation patterns from Typing Environment Variables and Configuration in TypeScript.
- Async plugin systems where each plugin returns a different type; generics and mapped types can aggregate results.
- Client-side apps that fetch mixed content (JSON, text, binary) and need precise per-call typing—overloads or conditional generics work well.
If your codebase still includes JavaScript files, JSDoc-based typing can help bring similar assurances to async functions; see Using JSDoc for Type Checking JavaScript Files (@typedef, @type, etc.) for practical patterns.
Conclusion & Next Steps
Typing Promises that resolve to different types doesn't have to be messy. Choose the right abstraction—unions, discriminated unions, generics, or overloads—based on how the resolved value is determined. Add runtime validation where necessary, use type predicates to narrow unknowns safely, and leverage modern TypeScript features to keep APIs ergonomic. Next, try converting a few of your existing Promise-returning functions to these patterns and run the TypeScript compiler in strict mode to surface issues early.
Recommended next reads from our library: Using Type Predicates for Custom Type Guards and the Practical Case Study: Typing a Data Fetching Hook for hands-on examples.
Enhanced FAQ
Q1: Should I always use discriminated unions for Promises that can resolve to different types?
A1: Discriminated unions are excellent when the mutually exclusive outcomes are known and simple to identify via a small discriminant key. They make narrowing trivial and safe. However, if the resolved type directly depends on the caller's input (e.g., passing a format parameter), generics or overloads may be a better fit. Choose discriminated unions for server responses, action-result patterns, and explicit state variants.
Q2: When is a generic + conditional type better than overloads?
A2: Use conditional generics when the relationship between input and output is systematic and can be expressed concisely with generics and conditionals. Overloads are clearer when there are a small number of discrete input shapes you want explicitly documented and discoverable in tooling. Overloads can be more readable for consumers but require maintaining multiple signatures.
Q3: How do I type Promise.all so I don't lose element-specific types?
A3: Use tuple literals or const assertions when passing items to Promise.all so TypeScript infers a tuple rather than a widened array. Example: Promise.all([p1, p2] as const). Modern TypeScript also does a good job inferring tuples for arrays with literal types; ensure your promises are typed precisely. For safer indexing, consider enabling noUncheckedIndexedAccess.
Q4: What about Promise.any and Promise.race—how to detect which promise resolved?
A4: The resolved value of race/any will be the value returned by the winning Promise. If you need to know which promise resolved, tag the resolved values before racing. For example, map each promise to resolve an object with a source identifier and the actual value. This preserves both identity and value type in the union.
Q5: How do I keep runtime validation in sync with TypeScript types?
A5: Keep a single source of truth for validation, ideally using a schema-based validation library (zod, io-ts) that can produce both runtime validation and TypeScript-inferred types. If you write manual validators, keep small, well-tested guard functions and reuse them. The pattern of unknown + isX predicates is recommended for clarity—see our guide on Using Type Predicates for Custom Type Guards.
Q6: Is it ever ok to return Promise
A6: Returning Promise
Q7: How do I type async initialization for configuration or env-based values?
A7: For runtime configuration that requires asynchronous initialization (e.g., fetching secrets or remote config), return a typed Promise for the initialized configuration type and validate with a schema. See Typing Environment Variables and Configuration in TypeScript for examples of runtime schemas and declaration merging techniques to blend static and dynamic types.
Q8: My Promise resolves to different structures depending on feature flags—how should I model that?
A8: Use discriminated unions keyed by the feature flag state or provide a generic parameter that indicates the enabled feature set. If the variation is big and complex, consider splitting into separate API endpoints or functions to reduce complexity and make types clearer. You can also model the full union and provide helper type guards for each variant.
Q9: Should I be concerned about build performance with complicated types?
A9: Yes, extremely complex types can slow down IDE responsiveness and type-check durations. Keep performance-sensitive code simpler and localize the most complex types to your public API boundaries. If type-check performance is a problem, consult Advanced TypeScript Compiler Flags and Their Impact and consider faster tooling like esbuild or swc for incremental builds.
Q10: Any tips for migrating existing code that uses a lot of any?
A10: Start by enabling strict mode and slowly eliminate any by replacing with unknown and adding validators. Add types to the most critical boundaries first (network responses, config, public API). Use type guards and schema libraries to validate and infer types. Refer to the examples in this article and the linked resources for step-by-step strategies.
--
If you'd like, I can generate a small, runnable example repository that demonstrates several of the patterns here (including a typed fetcher with runtime validation and a Promise.all tuple example). I can also provide a checklist for migrating existing Promise-returning functions in a medium-sized codebase.
For hands-on practice, check the related deep dives: Practical Case Study: Typing a Data Fetching Hook, Using Type Predicates for Custom Type Guards, and Typing Environment Variables and Configuration in TypeScript.
