[SOLVED] Type guard for union type generic notification (Notifier | Success) does not work

Issue

I have the following structure:

type Result<TNotification, TSuccess> = Success<TNotification, TSuccess> | Notifier<TNotification, TSuccess>;

abstract class ResultAbstract<TNotification, TSuccess> {
protected constructor(protected readonly result: TNotification | TSuccess) {}

abstract isSuccess(): this is Success<TNotification, TSuccess>;

abstract isFailure(): this is Notifier<TNotification, TSuccess>;

abstract getData(): TNotification | TSuccess;
}

class Success<F, S> extends ResultAbstract<F, S> {
constructor(readonly data: S) {
  super(data);
}

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

isFailure(): this is Notifier<F, S> {
  return false;
}

getData(): S {
  return this.result as S;
}

}

class Notifier<E, _> extends ResultAbstract<E, _> {
constructor(readonly data: E) {
  super(data);
}

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

isFailure(): this is Notifier<E, _> {
  return true;
}

getData(): E {
  return this.result as E;
}
}

class Fail {}

class User {
  constructor(private name: string){}
}


I am basically trying to build a notification pattern for handling exceptions on my code flow. I have the following functions:

const findUser = (): Result<Fail, User> => {
    if(Math.random() * 0.5 < 0.2)
        return new Success(new User('Tony stark'))
    
    return new Notifier(new Error('Usuário não existe'))
}


const userExists = () => {
    const result = findUser()

    if(result.isFailure()) return 'Not found'

    const user = result.getData() // Property 'getData' does not exist on type 'never'.(2339)


    return user.name
}

console.log(userExists())

The Result type is a union of either Notifier (For errors) or Success, but if I call one of the type guards on the union it does not narrow the remainder (the "else" statement) to the other half of the union.

If I explicitly call the second type guard, it works as expected:

const userExists = () => {
    const result = findUser()

    if(!result.isSuccess()) return 'Not found'

    const user = result.getData() 

    return user.name
}

console.log(userExists()) // it works!

The sample code is here in this playground

Can anyone help me understand this?

Solution

The core of the problem here is that Success and Notifier are both structurally the same, so TypeScript thinks that if the following if statement has a chance of returning true for one of those classes, it would return true for both:

if(result.isFailure())

Hence, the result value type after this block is automatically inferred as never.
I have created an example to illustrate this here.

This problem can easily be fixed by introducing a unique field/method within one of the two classes or alternatively adding a private field/method.

The explanation above will fix the issue you are having and can be used to describe the behaviour of the if(result.isFailure()) statement. However, if(result.isSuccess()) on the other hand still appears to work with your original code. The reason for that is because you have introduced dynamicity in the order of how the nested generic types are assigned to the extending classes:

extends ResultAbstract<F, S> vs extends ResultAbstract<E, _>

Given the above, the classes can be considered to be different, however TypeScript will still think that Success and Notifier are the same as the is predicates tell it that Success can be Notifier (via the isFailure method) and Notifier can be Success (via the isSuccess method). Hence, all you need to do is remove either the isSuccess or the isFailure method and the code will start to work as it will tell TypeScript that it can only be one.
Because your code only uses two classes, this would be the most suitable solution.

Answered By – Ovidijus Parsiunas

Answer Checked By – Dawn Plyler (BugsFixing Volunteer)

Leave a Reply

Your email address will not be published. Required fields are marked *