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
.
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.