Search code examples
typescript

TypeScript narrowing type to `never` undesirably


While at a project at work (TypeScript 5.1.3), I've stumbled upon the following convention for passing errors through the app's abstraction layers:

export class Success<E, S> {
  readonly value: S;

  constructor(value: S) {
    this.value = value;
  }

  isSuccess(): this is Success<E, S> {
    return true;
  }

  isError(): this is MyError<E, S> {
    return false;
  }
}

export class MyError<E, S> {
  readonly value: E;

  constructor(value: E) {
    this.value = value;
  }

  isSuccess(): this is Success<E, S> {
    return false;
  }

  isError(): this is MyError<E, S> {
    return true;
  }
}

export type Either<E, S> = MyError<E, S> | Success<E, S>;

export const error = <E, S>(value: E): Either<E, S> => {
  return new MyError(value);
};

export const success = <E, S>(value: S): Either<E, S> => {
  return new Success(value);
};

So every function will have a return type using the Either helper type and then return either a success(value) or error(value) and then the callee evaluates the result using the .isError() or isSuccess() functions. Here is an example:

type SomeErrorType = { message: string };

function otherFunction(): Either<SomeErrorType, string> {
  return success('some value');
}

async function main() {
  const result = otherFunction();

  if (result.isError()) {
    // in this case will evaluate to false since otherFunction returns a success("some value")
    console.log('isError');
    return;
  }

  console.log('isSuccess');
  // will evaluate to true
  console.log(result.value);
}
main();

The thing that is bugging my head is that if I set the Either error type to any or unknown and try to handle the isError() first, then result is narrowed to type never. Example:

function otherFunction(): Either<string, string> {
  return success('some value');
}

async function main() {
  const result = otherFunction();

  if (result.isError()) {
    console.log('isError');
    return;
  }

  console.log('isSuccess');
  console.log(result.value);
  // i can't access result.value because result is now narrowed to `never`
}
main();

I don't understand why this would evaluate to never and not any. If I set the Error on Either to boolean it narrows correctly to boolean, but setting to string also does narrows the result to never.


Solution

  • TypeScript's type system is largely structural, meaning that if two types have the same structure or shape (that is, the same members of the same types), then TypeScript considers the two types to be the same type. And unfortunately, your definition of Success<E, S> and MyError<E, S> only differ by the swapping of E and S. That is, TypeScript has no way to tell the difference between Success<T, U> and MyError<U, T>. And Either<T, T> is the union type Success<T, T> | MyError<T, T>, which is written like two types, but collapses to just one.

    This is a poor design choice in a structural type system. You should add a distinguishing member to Success and MyError, like this:

    export class Success<E, S> {
      readonly success = true; // true, structurally distinct from MyError
      // ✂ ⋯ snip ⋯ ✂
    }
    
    export class MyError<E, S> {
      readonly success = false; // false, structurally distinct from Success
      // ✂ ⋯ snip ⋯ ✂
    }
    

    Here I've added a success property to both, where one is the literal type true and the other is the literal type false. Those are distinct types, so TypeScript will no longer get Success<T, U> and MyError<U, T> confused.

    That will fix your problem, but the question is about why this is happening, so I'll continue explaining that.


    As I mentioned, your Success<T, U> and MyError<U, T> are structurally identical. But it's worse than that. Success<E, S> is covariant in S and completely independent of E and therefore bivariant in E (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript). So Success<E1, S1> extends Success<E2, S2> whenever S1 extends S2 and MyError<E1, S1> extends MyError<E2, S2> whenever E1 extends E2. So it's very easy for TypeScript to get them confused. Success<E1, S1> extends MyError<E2, S2> whenever E1 extends S2 or S1 extends E2, and that means Either<E, S> isn't actually a union of two distinct types, whenever E extends S or S extends E. So if S and E are the same, or one is a wide type like unknown or any, one of the types in that union completely absorbs the other one.

    That means if you write a type predicate that is intended to narrow an Either<E, S> to MyError<E, S>, then when the type predicate returns false, TypeScript eliminates MyError<E, S> from Either<E, S>. But in situations where Success<E, S> extends MyError<E, S>, then TypeScript eliminates both Success and Error from the union. It narrows all the way to never.

    That's what happens whenever you have Either<string, string> or Either<any, string> or Either<unknown, string>. For Either<string, string>, TypeScript says "well, if it isn't a MyError<string, string> then it can't be a Success<string, string> either because those are the same type." For Either<any, string>, TypeScript says 'well, if it isn't a MyError<any, string> then it can't be a Success<any, string> because string extends any. Oops.


    Again, the fact that at runtime MyError and Success are two different classes from two different declarations isn't something TypeScript can really "see" easily. TypeScript's type system isn't nominal, so it usually does not care if two types were declared in different places or with different names. The way to make TypeScript see two types as different is to give them differing structure. A single success property of type true or false is sufficient to make Either<E, S> a discriminated union, but any distinguishing structure would also work.

    Playground link to code