Type guard with complement in false branch
Asked Answered
H

2

6

I'm trying to narrow down a complex type using a type guard. I would like the false branch of the type guard to understand the complement to the narrowed down type.

interface Model { side: 'left' | 'right'; }
interface LeftModel { side: 'left'; }
interface RightModel { side: 'right'; }
type Either = LeftModel | RightModel;

function isLeft(value: Either): value is LeftModel { // else is RightModel
  return value.side === 'left';
}

This does not seem possible, at least not the way I'm going about it. Typescript is happy to infer that an Either can be a model, but it doesn't accept that a Model can be an Either. This is giving an error:

declare const model: Model;
isLeft(model) // ts(2345)

Is this problem fundamentally unsolvable?

If not, how do I get the false branch to narrow down to the complement?

see full example in this Typescript Playground

EDIT

It appears in this naive example that Model and Either are equivalent, but this probably cannot be generalised. I could make some progress by combining two type guards, in an attempt to inform the type system that Model is in fact a valid Either (see this new Playground). This however leaves me with an unwanted branch (see line 22) so isn't fully satisfactory.

Is there a way to let the type system know that Either and Model are strictly equivalent?

I do not particularly need to use either type guards or union types, this was my first attempt at solving the problem but created problems of its own. Union types would only be viable if we can guarantee that the union of a narrowed type and its relative complement are indeed equivalent to the type narrowed down. This relies on the type system having an understanding of complement, which might not be the case (yet). See this typescript complement search and the handbook on utility types

Someone suggesting using fp-ts and/or monocle-ts to solve this but some of these functional programming concepts are still going over my head. If someone knows how to apply them here though that would be nice. Either sounds like it could help here...

Haygood answered 7/6, 2019 at 16:31 Comment(0)
A
3

The union operator | does not perform a union on the types of the properties of the types specified in the union.

i.e.

type Either = LeftModel | RightModel === { side: 'left ' } | { side: 'right' } 
           !== { side: 'left' | 'right' } === Model

Either is strictly a union of LeftModel and RightModel not a union of type side and side

Excerpt of the error, I think this says it all,

Argument of type 'Model' is not assignable to parameter of type 'Either'. Type 'Model' is not assignable to type 'RightModel'. Types of property 'side' are incompatible. Type '"left" | "right"' is not assignable to type '"right"'. Type '"left"' is not assignable to type '"right"'.

Aggri answered 7/6, 2019 at 16:59 Comment(1)
Thanks a lot for this comment. You are right. This kind of breaks my heart tho as I would have expect both types to be equivalent...Penetration
K
2

You can actually do this with a type union and without a type guard:

interface Model { side: 'left' | 'right'; }
interface LeftModel extends Model { side: 'left'; }
interface RightModel extends Model { side: 'right'; }

function whatever(model: LeftModel | RightModel) {
  model.side // left or right

  if (model.side === 'left') {
    model.side; // left - model is also LeftModel at this point
  } else {
    model.side; // right - model is a RightModel
  }
}

Playground

The inheritance from Model is actually optional, as it's really the type union that does the work here. But it helps to constrain any subclasses to 'right' or 'left' only.

Hopefully this is the type of thing you're trying to accomplish?

While the type guard isn't necessary, it can still be used. It can't figure out the complementary RightModel type - but the caller can, by already having value constrained to a union of LeftModel and RightModel.

interface Model { side: 'left' | 'right'; }
interface LeftModel extends Model { side: 'left'; }
interface RightModel extends Model { side: 'right'; }

function isLeft(value: Model): value is LeftModel {
  return value.side === 'left';
}

function whatever(model: LeftModel | RightModel) { // This union makes the else branch work
  model.side // left or right

  if (isLeft(model)) {
    model.side; // left - model is also LeftModel at this point
  } else {
    model.side; // right - model is a RightModel
  }
}

With type guard

Kep answered 7/6, 2019 at 16:41 Comment(5)
When using the Model type instead of the union type, the type system does not infer the narrowed down types in the if/else statement branches. Or rather, you cannot use an instance of Model as input where the union type is specified (as in, that function)Haygood
The important part is the union should be used wherever you need to differentiate between the model types, you simply can't use Model at this point. The type guard can actually take the union, or Model as an argument and produce the same result.Kep
Trying to feed a Model instead of a union type in the type guard will result in a ts2345 error, see the Playground I linked in my example (line 20)Haygood
Yeah that's kinda my point. If you pass a Model to the type guard then it will only be able to tell you if it's a LeftModel or not. However if you pass it a union of 2 types (regardless of the type of the guard's argument) then the compiler will infer that if it's not Type A it must be Type B. See my 2nd playground link.Kep
Bottom line is there must be a Union, the compiler can't assume that if a Model isn't a LeftModel then it must be a RightModel, because that's simply not true.Kep

© 2022 - 2024 — McMap. All rights reserved.