typescript

How to help typescript infer complement of class type guard


I have this code:

class State<P, N> {
    private constructor(
        private readonly _isPositive: boolean,
        private readonly _positiveStateVal?: P,
        private readonly _negativeStateVal?: N
    ) { }

    static Positive<P>(stateVal: P): State<P, never> {
        return new State<P, never>(true, stateVal, undefined as never)
    }

    static Negative<N>(stateVal: N): State<never, N> {
        return new State<never, N>(true, undefined as never, stateVal)
    }

    isPositive(): this is State<P, never> {
        return this._isPositive
    }

    isNegative(): this is State<never, N> {
        return !this._isPositive
    }
}

const x: State<number, number> = State.Positive(32)

if (x.isPositive()) {
    x // State<number, never>
} else {
    x // State<number, number> while I would like State<never, number>
}

Is it possible to help typescript infer that the else branch of x.isPositive() should lead to the instance being State<never, P>? I would like to avoid having to use else if or have to restructure the code as a discriminated union since that comes with other issues.

I need this because a State instance can only have either a _positiveStateVal or a _negativeStateVal, never both. A State<P, never> indicates it has a positive val, while State<never, N> means that it has a negative val. State<P, N> arises in the case where, for example, a function returns both a State<P, never> and a State<never, N> due to internal logic. Hence I need two methods isPositive() and isNegative() to narrow the type to a definite positive or negative state instance.

Link to the playground


Solution

  • Nothing in the type system prevents a State<P, N> from having both a _positiveStateVal and a _negativeStateVal, and nothing about the type of State<P, N> implies that it is assignable to the union type State<P, never> | State<never, N>. You may have intended this, and written private constructors to guarantee it at runtime, but it's not represented in the types, so TypeScript cannot see it. While you can use a type predicate to narrow State<P, N> in the true case, it won't do anything in the false case, because the mere fact that you don't have a State<P, never> does not imply that you do have a State<never, N>.

    TypeScript's behavior on false type predicates is hardcoded in the language: it just filters unions, it doesn't "negate" anything. TypeScript doesn't even have negated types (as requested in the quite-old-now microsoft/TypeScript#4196) so there's nothing TypeScript can even do automatically here for non-unions. With negated types you could imagine that the false branch narrows to not State<P, never>, but that type doesn't exist... and it wouldn't work anyway, see the previous paragraph.

    There's a longstanding open feature request at microsoft/TypeScript#15048 to allow for more fine-grained control over type predicates, so that you could specify both the true and false behaviors. If it existed, maybe you could make isPositive return this is State<P, never> else State<never, N> and then things would work as you want. But it does not exist, so you can't do that.


    At this point it should be clear that your approach is fighting against the TypeScript type system. You really want State<P, N> to be a union. But class statements never produce union types, so you just cannot do this with a single class statement. The approach that works with the type system is to just have two separate classes, so that an invalid state simply cannot be represented at all. Something like:

    class Positive<P> {
      private constructor(private readonly _posVal: P) { }
      static create<P>(val: P) { return new Positive(val) }
      isNegative(): this is never { return false; }
      isPositive(): this is Positive<P> { return true; }
      posVal(): P { return this._posVal }
      negVal(): undefined { return undefined }
    }
    class Negative<N> {
      constructor(private readonly _negVal: N) { }
      static create<N>(val: N) { return new Negative(val) }
      isNegative(): this is Negative<N> { return true; }
      isPositive(): this is never { return false; }
      posVal(): undefined { return undefined }
      negVal(): N { return this._negVal; }
    }
    
    type State<P, N> = Positive<P> | Negative<N>
    const State = {
      Positive: Positive.create,
      Negative: Negative.create
    }
    

    And now if you use a State<P, N> and apply type guards to it, everything just works:

    const x: State<number, number> =
      Math.random() < 0.5 ? Positive.create(32) : Negative.create(20)
    
    if (x.isPositive()) {
      x // const x: Positive<number>
      console.log(x.posVal().toFixed())
    } else {
      x // const x: Negative<number>
      console.log(x.negVal().toFixed())
    }
    

    Maybe this approach doesn't meet your use cases, but you can see that conceptually at least it's more straightforward and in keeping with TypeScript's type system. Perhaps some other workaround would be better, but this is effectively out of scope here, since the answer to the question as stated is: no, this is not possible in TypeScript, at least not without microsoft/TypeScript#15048.

    Playground link to code