Search code examples
typescripttypescript-genericsunion-types

How to merge generic parameters in union?


I have a function that return either a Result<T, never> or a Result<never, E> Typescript infers the return type as the union of both Result<T, never> | Result<never, E>. Why don't Typescript merge the types to Result<T, string> Is it possible to achieve this without explicitly annotating the function with Result<T, string> ?

Stackblitz

class Result<out T, out E> {
  readonly #ok: boolean;
  readonly #inner: T | E;

  private constructor(ok: boolean, inner: T | E) {
    this.#ok = ok;
    this.#inner = inner;
  }

  static Ok<T>(value: T): Result<T, never> {
    return new Result<T, never>(true, value);
  }

  static Err<E>(error: E): Result<never, E> {
    return new Result<never, E>(false, error);
  }
}

function mayThrow(success: boolean) {
  try {
    if (success) {
      return Result.Ok(42);
    } else {
      throw 'error';
    }
  } catch {
    return Result.Err('error');
  }
}

const res = mayThrow(true);

// ACTUAL TYPE => const res: Result<number, never> | Result<never, string>
// DESIRED TYPE => const res: Result<number, string>

Solution

  • TypeScript is not an automated theorem prover nor a full constraint solver. It uses various heuristics to infer, compare, and evaluate types. There are many situations in which TypeScript can verify that a complex type X is equivalent to some simpler type Y, but does not automatically simplify it. Such simplification would be extra work the type checker would need to perform whenever evaluating a complex type, and most of the time the extra work would provide no benefit since in general complex types do not simplify. There are various issues in GitHub where people say "I expected TypeScript to notice such-and-such logical truth about my types" and the response is "sorry, TypeScript is not a proof solver". See microsoft/TypeScript#57511 for example.

    So even if it is correct to simplify Result<T1, E1> | Result<T2, E2> | Result<T3, E3> to Result<T1 | T2 | T3, E1 | E2 | E3>, it doesn't imply TypeScript will do so itself. If you have a case where TypeScript can recognize the correctness of assigning a value to a desired type, but it doesn't infer that type, then your recourse is to annotate the type explicitly:

    function mayThrow(success: boolean): Result<number, string> {
      try {
        if (success) {
          return Result.Ok(42);
        } else {
          throw 'error';
        }
      } catch {
        return Result.Err('error');
      }
    }
    

    Anything else will be much more complicated, where you define your own utility type and helper function:

    type CollapseResult<R extends Result<unknown, unknown>> =
      {} & (Result<
        R extends Result<infer T, any> ? T : never,
        R extends Result<any, infer E> ? E : never
      >);
    
    function collapseResult<
      R extends Result<unknown, unknown> & CollapseResult<R>
    >(r: R): CollapseResult<R> {
      return r
    }
    

    And then jump through more hoops to use it:

    function _mayThrow(success: boolean) {
      try {
        if (success) {
          return Result.Ok(42);
        } else {
          throw 'error';
        }
      } catch {
        return Result.Err('error');
      }
    }
    
    function mayThrow(success: boolean) {
      return collapseResult(_mayThrow(success));
    }
    // function mayThrow(success: boolean): Result<number, string>
    

    TypeScript has successfully verified that mayThrow() returns Result<number, string> and the fact that collapseResult(_mayThrow(success)) can be called without error is an indication that TypeScript agrees that Result<number, never> | Result<never, string> is assignable to Result<number, string>. If you're going to write a lot of code like mayThrow() then maybe collapseResult() is a useful helper. But if you're doing it once, it's much simpler to just annotate the return type of mayThrow() directly.

    Playground link to code