Typescript: Narrowing discriminated unions with user-defined type guards

Created on 21 Oct 2016  路  7Comments  路  Source: microsoft/TypeScript

TypeScript Version: 2.0.3

Code

type Cheese = {
  type: 'brie';
  brieOnly: true,
} | {
   type: 'cheddar';
  cheddarOnly: true,
}


function narrows<Wide extends string, Narrow extends Wide>(w: Wide, n: Narrow): w is Narrow {
    return w === n;
}


function doSomethingWithCheese(cheeseSample: Cheese) {
    if (cheeseSample.type === 'cheddar') {
        cheeseSample.cheddarOnly // ok
    }

    if (narrows(cheeseSample.type, 'cheddar')) {
        const t: 'cheddar' = cheeseSample.type; // has narrowed 
        cheeseSample.cheddarOnly // not ok, didn't narrow discriminated union
    }
}

Expected behavior:

Discriminated unions would work with type-guards in this way.

Actual behavior:

It doesn't, and I have to write a load of stuff like this in unit-tests:

    if (cheese.type === 'cheddar') {
          assert(/* some assertions using cheddar specific properties */);
    } else {
           throw Error("wrong type of cheese");
        }

rather than doing it in one step with an assertion throwing version of narrows:

    if (assert.narrows(cheese.type, 'cheddar')) {
          assert(/* some assertions using cheddar specific properties */);
    }
Duplicate

Most helpful comment

This is still an issue in 3.x. I had a slightly simpler variant that didn't have a type-parameterized type guard, but I think it's the same basic issue:

interface Foo {
    type: "foo";
    foo: string;
} 

interface Bar {
    type: "bar";
    bar: string;
}

type FooBar = Foo | Bar;

function isFooType(t: "foo" | "bar"): t is "foo" {
    return true;
}

const f: FooBar = null as any;

if (f.type === "foo") {
    f.foo; // works
}

if (isFooType(f.type)) {
    f.foo; // breaks
}

playground link

This is a bummer, cause it means that a user-defined type guard deep in the type hierarchy may get bubbled up with "wrapper" type guards for containing types in order to discriminate them:

function isFoo(foobar: Foo | Bar): foobar is Foo {
    return isFooType(foobar.type);
}

All 7 comments

What is narrowsInUnion in your example? I cannot find it.

@aluanhaddad I think that's just a typo for narrows.

@timruffles It doesn't seem like what you're proposing generalizes well. Your narrows invocation is oblivious to the type of cheeseSample, you're just passing in an arbitrary expression of type string that you establish for the remainder of the function to be a particular string literal.

What you want to do can be accomplished with the existing type system, it is just somewhat awkward:

type Cheese = {
    type: 'brie';
    brieOnly: true,
} |
{
    type: 'cheddar';
    cheddarOnly: true,
};

function narrows<Wide, Narrow extends Wide>(w: Wide, f: (w: Wide) => Narrow): w is Narrow {
    return f(w) !== undefined;
}

function doSomethingWithCheese(cheeseSample: Cheese) {
    if (cheeseSample.type === 'cheddar') {
        cheeseSample.cheddarOnly;
    }

    if (narrows(cheeseSample, c => c.type === 'cheddar' ? c : undefined)) {
        const t: 'cheddar' = cheeseSample.type;
        cheeseSample.cheddarOnly;
    }
}

Right now I'm relying on type inference to make this work, but the fact that you have to use undefined and return the actual instance instead of a boolean is awkward. Possibly with some more indirection for type inference and narrowing in the language, this could be expressed less hackishly.

This is still an issue in 3.x. I had a slightly simpler variant that didn't have a type-parameterized type guard, but I think it's the same basic issue:

interface Foo {
    type: "foo";
    foo: string;
} 

interface Bar {
    type: "bar";
    bar: string;
}

type FooBar = Foo | Bar;

function isFooType(t: "foo" | "bar"): t is "foo" {
    return true;
}

const f: FooBar = null as any;

if (f.type === "foo") {
    f.foo; // works
}

if (isFooType(f.type)) {
    f.foo; // breaks
}

playground link

This is a bummer, cause it means that a user-defined type guard deep in the type hierarchy may get bubbled up with "wrapper" type guards for containing types in order to discriminate them:

function isFoo(foobar: Foo | Bar): foobar is Foo {
    return isFooType(foobar.type);
}

@masaeedu the narrows call is not attempting to narrow cheeseSample, it's narrowing its type field. I'm suggesting that the information we gain from that narrowing is used to infer the type of cheeseSample.

If you re-read my example you'll see it's the same narrowing & inference logic as is used in the primitive example .type === 'cheese I gave above it, just with user-defined type guards rather than ===.

Also running into this issue as of 3.6.0-dev.20190603. Another simple example:

interface Square {
    kind: "square";
    size: number;
}

interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}

type Shape = Square | Rectangle;

Using a basic Shape discriminated union example.

Demonstrates expected discriminated union type inference:

const shape1: Shape = JSON.parse('{ "kind": "square", "size" : 10 }');

console.log(shape1.kind); // compiles, as expected
console.log(shape1.size); // compiler error, as expected
if (shape1.kind === "square") {
    console.log(shape1.size); // compiles, as expected
}

Demonstrates incorrect discriminated union type inference using type guard:

const isShape = (value: any): value is Shape => typeof value === 'object' && value !== null && typeof value.kind === 'string' /* && etc... */;

const shape2: unknown = JSON.parse('{ "kind": "square", "size" : 10 }');

if (isShape(shape2)) {
    console.log(shape2.kind); // compiles, as expected
    console.log(shape2.size); // compiler error, as expected
    if (shape2.kind === "square") {
        console.log(shape2.size); // compiler error, NOT expected
    }
}

playground

The narrows definition is not something that can narrow the outer type, as discussed above

Alternately, duplicate #30557

Was this page helpful?
0 / 5 - 0 ratings