microsoft/TypeScript

TypeScript drops generic branch types in async functions returning Result/Err/Ok

Closed this issue · 5 comments

🔍 Search Terms

result monad
generic inference
type inference control flow
union type merging
generic unification
conditional branches return type
lost union type
err ok result inference
preserve generic branches
return type inference bug
typescript drops union type
async function result type inference
missing types

✅ Viability Checklist

⭐ Suggestion

Hey folks 👋

I think I just ran into a really annoying inference hole, and I’m surely not the first one.
When returning multiple instances of a generic “Result” (or “Ok”/“Err” monad) from an async function, TypeScript drops some of the error types completely unless you explicitly annotate the return type.

I’m not asking TypeScript to infer full algebraic data types - just to preserve valid type information that already exists.

If all return branches are explicitly typed (for example:
Err<never, BadRequestError> and Err<never, ForbiddenError>),
the compiler should keep that as a discriminated union of both, rather than silently collapsing one of the generic parameters (like E) to never.

It currently feels like TypeScript “gives up” on merging generics with different instantiations, even when the outer generic constructor (e.g. Err<...>) is clearly the same.
Surely I’m not the first one annoyed by this - it’s a very common result-monad pattern.

Is there any ongoing discussion or design note about improving generic merging across conditional branches?
This seems like a pretty big ergonomic loss, especially compared to languages like Rust or Go, where return unions are explicit and reliable.

📃 Motivating Example

Here’s a minimal repro (no libraries, plain TS):

Typescrit playground link: CLICK

If you hover the inferred type of assignVerbose, you’ll see something like:

Promise<
  | Ok<number, never>
  | Err<never, BadRequestError>
  | Err<never, ConfigError>
>

👉 The ForbiddenError is just gone from the union.
Even though it’s clearly one of the branches, TypeScript “squashes” it out because it can’t unify multiple instantiations of the same generic (Err<never, X>).

If I rewrite the same function in a chained or “functional” style (like neverthrow’s .andThen), everything works fine - the generic type parameter for E is preserved across the pipeline and inferred as:

Promise<Result<number, BadRequestError | ConfigError | ForbiddenError>>

So the difference isn’t semantic - it’s just that the compiler can’t reconcile Err<never, A> | Err<never, B> into Err<never, A|B> during inference.

💻 Use Cases

This basically means that any codebase using Result/Ok/Err patterns (neverthrow, fp-ts, custom monads, etc.) loses actual error type safety if it uses normal control flow instead of chaining.

TypeScript silently generalises the generic parameters to never instead of unioning them, which breaks the expected contract and can lead to unhandled runtime errors that TS can’t see.

This happens in many places:

  • Service layers that validate business rules and return typed results.
  • Functional-style APIs that rely on composable Result/Either flows.
  • Async functions that use multiple return branches for Ok/Err values.

In larger codebases, it leads to:

  • False confidence in exhaustiveness checks (result.error missing cases).
  • Redundant type annotations everywhere just to force correct unions.

The type system can’t track all cases consistently, and well...throwing errors all around in 2025 feels like driving a car without seatbelts 🪩🚗💥

nmain commented

Duplicate of #60033. Add some properties to your error subclasses if you don't want them to be combined.

Duplicate of #60033. Add some properties to your error subclasses if you don't want them to be combined.

I get that it’s technically subtype reduction — but in this case, that reduction discards valuable semantic information.

Err<never, ForbiddenError> | Err<never, BadRequestError> is not redundant the way string | string is -
those are distinct instantiations representing different logical outcomes. Are there any plans of addressing it in the future?

nmain commented

that reduction discards valuable semantic information

To the typechecker, it's not valuable information. Even if there was no subtype reduction here, you'd still be able to assign values of type BadRequestError to variables of type ForbiddenError and vice versa and other combinations. That's how structural typing works.

@AlexJohnSadowski there are ways to convince ts to use nominal types, e.g.

class BadRequestError extends Error { private _useNominal:undefined; }
class ForbiddenError extends Error { private _useNominal:undefined; }
class ConfigError extends Error { private _useNominal:undefined; }

unique symbols work, too

This issue has been marked as "Question" and has seen no recent activity. It has been automatically closed for house-keeping purposes.