Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proposal: Narrow Discriminated Unions by discarding types with no possible values #13300

Closed
nahuel opened this issue Jan 5, 2017 · 9 comments
Labels
Duplicate An existing issue was already created

Comments

@nahuel
Copy link

nahuel commented Jan 5, 2017

It will be nice if TS narrowed Discriminated Unions by discarding types with no possible values (e.g. {tag: 'a' & 'b', x : 1}).

Example:

type A = {tag: 'a', x: 1} 
type B = {tag: 'b', y: 1}
type TaggedAsB = {tag: 'b'}

type T = (B | A) & TaggedAsB  // {tag: 'a' & 'b', x: 1} | {tag: 'b', y: 1}

let o : T
o.y       // make legal, because the only possible values have type {tag: 'b', y: 1} 

Use case:

type Event = ({eventName : "EVENT_A", dataA : any} | 
              {eventName : "EVENT_B", dataB : any})

// define a callback for an specific event, with this new feature you can use a tag
// and the intersection type operator to select an specific case from the Event DU
function handlerEventA(event : Event & {eventName: "EVENT_A"}) {
      // with this new feature, here we don't need a runtime typeguard, 
      // TS already knows what specific DU case is event:
      let x = event.dataA 
}

This comes from the discussion at #13203

@nahuel nahuel changed the title narrow Discriminated Unions by discarding types with no possible values proposal: Narrow Discriminated Unions by discarding types with no possible values Jan 6, 2017
@akarzazi
Copy link

akarzazi commented Jan 7, 2017

Isn't
function handlerEventA(event : EventA )
better than
function handlerEventA(event : Event & {eventName: "EVENT_A"})
?

@nahuel
Copy link
Author

nahuel commented Jan 7, 2017

@akarzazi sure, if you create a type name for each possible event (note each "subtype" in the Event DU as defined in my not so good use case is anonymous). But this proposal solves a more general problem of having a DU type and wanting to create statically a new type from it by extracting one or more specific DU cases. I think it can be useful in other scenarios, for example, if you need to make use of an implicit relation between two disjoint DU's with similar tags:

type EventA = {eventName : "EVENT_A", dataA : any, confidentialDataA : any}
type EventB = {eventName : "EVENT_B", dataB : any, confidentialDataB : any}
type EventC = {eventName : "EVENT_C", superConfidentialDataC : any}

type Event = EventA | EventB | EventC

// Events ready for transfer, stripped from confidential data and maybe with other info
type SecurizedEventA = {eventName : "EVENT_A", dataA : any, otherDataA : any}
type SecurizedEventB = {eventName : "EVENT_B", otherDataB : any}

// C not possible, too confidential!
type SecurizedEvent = SecurizedEventA | SecurizedEventB  

function securize<E extends Event>(e : E) : SecurizedEvent & {eventName : E["eventName"]} {
  ....
}

let e1 : EventB
let se1 = securize(e1)   // type: SecurizedEventB

let e2: EventC
let se2 = securize(e2)   // error!

In any case, I don't see why this can't be implemented, and I think is good to exploit every possible opportunity to narrow DU's.

@akarzazi
Copy link

akarzazi commented Jan 8, 2017

Why would this fail ?

type Event = EventA | EventB | EventC
//...
let e2: EventC
let se2 = securize(e2)   // error!

since securize accepts Event

function securize<E extends Event>(e : E) : SecurizedEvent & E["eventName"] {
  ....
}

@nahuel
Copy link
Author

nahuel commented Jan 9, 2017

@akarzazi: Sorry, that signature was wrong, I edited my example and corrected it. Must fail because it has no possible return value.

@nahuel
Copy link
Author

nahuel commented Apr 26, 2017

Note, with this feature and mapped types you can define a fully typed addEventHandler function, not possible currently:

type Event = ({eventName : "EVENT_A", dataA : any} | 
              {eventName : "EVENT_B", dataB : any})

function addEventHandler<T extends Event['eventName']>(eventName : T, cb : (evt : Event & {eventName : T}) => any)  {
  this.on(eventName, cb)
}

function handleA(evt : Event & {eventName : 'EVENT_A'}) {
   evt.dataB // Compile error, invalid
   evt.dataA // Ok, evt is correctly narrowed
}

addHandler('EVENT_A', handleA); // OK
addHandler('EVENT_B', handleA); // Compile error

@RyanCavanaugh RyanCavanaugh added the Needs Investigation This issue needs a team member to investigate its status. label May 24, 2017
@dgreensp
Copy link

dgreensp commented Sep 2, 2017

I ran into this limitation as well, while trying to define a generic match function that would work for different discriminated union types that have a type: string property.

As you can see, the blocker that I run into is ultimately that TypeScript won't simplify a type like (One & { type: "one"; }) | (Two & { type: "one"; }) (which is equivalent to One):

interface One {
    type: 'one';
    aaa: number;
}
interface Two {
    type: 'two';
    bbb: string
}

type Foo = One | Two;

// (omitted: type Bar and other discriminated unions...)

type Matcher<X extends { type: string }, R> = {
    // this is where we do the intersection that is supposed to narrow X.
    // (in a perfect world we could iterate for T in X and then construct the
    // object {[T['type']]: (value: T) => R}; see
    // https://stackoverflow.com/questions/40823398/generic-match-function-for-discriminated-union-types-in-typescript
    // where someone is asking if this is possible, for the same reason.)
    [K in X['type']]: (value: X & { type: K }) => R
};

interface Match<X extends { type: string }> extends Function {
    <R>(x: X, matcher: Matcher<X, R>): R;
}

// (we could export this generic `match`, but we don't because
// of limitation #10571: all type parameters must be either
// inferred or not inferred.  So the consumer would have to
// write match<Foo,void>(...) or whatever their return-type is
// instead of just match<Foo>(...).  Also, the inference might
// choose too specific a type.)
function _match<X extends { type: string },R>(x: X, matcher: Matcher<X, R>): R {
    return matcher[x.type](x);
}

namespace Foo {
    // ...instead, we provide Foo.match.  other types like
    // Bar would have Bar.match.
    export const match: Match<Foo> = _match;
}

const f: Foo = { type: 'one', aaa: 123 };

Foo.match(f, {
    one(o) {
        // sadly, o is not One, it is:
        // (One & { type: "one"; }) | (Two & { type: "one"; })
    },
    two(t) {
        // same with t, it is:
        // (One & { type: "two"; }) | (Two & { type: "two"; })
    }
});

@dgreensp
Copy link

dgreensp commented Sep 2, 2017

I was able to come up with a modified, slightly more verbose idiom for declaring case classes that I'm going to proceed with, which features a correctly-typed Foo.match. If anyone who comes across this is curious, here's what I did:

namespace Foo {
    type Cases = {
        one: one;
        two: two;
    }

    export const match: genericMatch<Foo, Cases> = genericMatch;

    export interface one {
        case: 'one';
        aaa: number;
    }

    export interface two {
        case: 'two';
        bbb: string;
    }
}

type Foo =
    | Foo.one
    | Foo.two;

type Cases<X> = { [c: string]: X };
type Matcher<X, C, R> =
  | {[c in keyof C]: (x: C[c]) => R}
  | ({[c in keyof C]?: (x: C[c]) => R} & {default: (x: X) => R});

export interface genericMatch<X extends { case: keyof C }, C extends Cases<X>> extends Function {
    <R>(x: X, matcher: Matcher<X, C, R>): R;
}

export function genericMatch<X extends { case: keyof C }, C extends Cases<X>, R>(
    x: X, matcher: Matcher<X, C, R>
): R {
    return ((matcher[x.case] || matcher.default) as any)(x);
}


const f: Foo = { case: 'one', aaa: 123 };

Foo.match(f, {
    one(o) {
       // o: Foo.one
    },
    two(t) {
        // t: Foo.two
    }
});

Foo.match(f, {
    one(o) {
        // o: Foo.one
    },
    default(f) {
        // f: Foo.one | Foo.two
    }
});

@mhegazy
Copy link
Contributor

mhegazy commented Sep 5, 2017

looks like the same issue as #18210

@mhegazy mhegazy added Duplicate An existing issue was already created and removed Needs Investigation This issue needs a team member to investigate its status. labels Sep 5, 2017
@mhegazy
Copy link
Contributor

mhegazy commented Sep 20, 2017

Automatically closing this issue for housekeeping purposes. The issue labels indicate that it is unactionable at the moment or has already been addressed.

@mhegazy mhegazy closed this as completed Sep 20, 2017
@microsoft microsoft locked and limited conversation to collaborators Jun 19, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Duplicate An existing issue was already created
Projects
None yet
Development

No branches or pull requests

5 participants