Skip to content

Wishlist: support for correlated union types #30581

Description

@jcalz

TypeScript Version: 3.4.0-dev.20190323

Search Terms

correlated union record types

Code

type NumberRecord = { kind: "n", v: number, f: (v: number) => void };
type StringRecord = { kind: "s", v: string, f: (v: string) => void };
type BooleanRecord = { kind: "b", v: boolean, f: (v: boolean) => void };
type UnionRecord = NumberRecord | StringRecord | BooleanRecord;

function processRecord(record: UnionRecord) {
  record.f(record.v); // error!
 // error msg in TS3.2 and below: can't call union of functions
 // error msg in TS3.3 and up: record.v not assignable to never
}

Expected behavior:

The implementation of processRecord() code compiles without error

Actual behavior:

The call of record.f(record.v) complains either that record.f is not callable (TS3.2 and below) or that record.v is not of type never (TS3.3 and up).

Playground Link: 🔗

Discussion:

Consider the discriminated union UnionRecord above. How can we convince the compiler that the implementation of processRecord() is type safe?

I made a previous suggestion (#25051) to deal with this, but it was closed as a duplicate of #7294, since record.f was perceived as a union of functions, which were not callable. Now #29011 is in place to deal with unions of functions, and the issue persists. Actually it's arguably worse, since the error message is even more confusing. ("Why does the compiler want never here?")

For now the only workarounds are type assertions (which are not safe) or to walk the compiler manually through the different constituents of the union type via type guards (which is repetitive and brittle).

Here are some questions on Stack Overflow that I've seen asked which run into this issue:

I don't really expect a type-safe and convenient solution to appear, but when someone asks on StackOverflow or elsewhere about why they can't get this to work, I'd like to point them here (or somewhere) for an official answer.

Note that this problem also shows up as issues with correlations across multiple arguments, whether union-of-rest-tuples or generics:

type MultiArgVersion = UnionRecord extends infer U ? U extends UnionRecord ?
  [v: U['v'], f: U['f']] : never : never;
// type MultiArgVersion = [v: number, f: (v: number) => void] | 
// [v: string, f: (v: string) => void] | 
// [v: boolean, f: (v: boolean) => void];

function processMultiArg([v, f]: MultiArgVersion) {
  f(v); // error!
  // errror msg
  // Argument of type 'string | number | boolean' is not assignable to parameter of type 'never'.
}

function processMultiGeneric<T extends MultiArgVersion>(v: T[0], f: T[1]) {
  f(v); // error!
  // error msg
  // Argument of type 'string | number | boolean' is not assignable to parameter of type 'never'.
}

Thanks!

Related Issues:
#25051: distributive control flow analaysis suggestion which would deal with this
#7294: unions of functions can't usually be called
#29011: unions of functions can now sometimes be called with intersections of parameters
#9998: control flow analysis is hard

Activity

  1. jack-williams commented on Mar 25, 2019

    @jack-williams
    Collaborator

    Presumably there are good reasons why a pseudo existential isn't good enough? Might be worth adding the reasons to the issue.

    type Kinds = "n" | "s" | "b";
    type Reify<K extends Kinds> = K extends "n" ? number : K extends "s" ? string : K extends "b" ? boolean : never;
    type TRecord<K extends Kinds> = { kind: K, v: Reify<K>, f: (v: Reify<K>) => void}
    
    function processRecord<K extends Kinds>(record: TRecord<K>) {
      record.f(record.v);
    }
    
    const val: TRecord<"n"> = { kind: "n", v: 1, f: (x: number) => { } };
    processRecord(val)
  2. RyanCavanaugh commented on Mar 25, 2019

    @RyanCavanaugh
    Member

    We'd need some entirely new concept here, since we can't tell the OP example apart from this one (which is a wholly correct error):

    type NumberRecord = { kind: "n", v: number, f: (v: number) => void };
    type StringRecord = { kind: "s", v: string, f: (v: string) => void };
    type BooleanRecord = { kind: "b", v: boolean, f: (v: boolean) => void };
    type UnionRecord = NumberRecord | StringRecord | BooleanRecord;
    
    function processRecord(r1: UnionRecord, r2: UnionRecord) {
      r1.f(r2.v); // oops
    }
  3. jcalz commented on Mar 26, 2019

    @jcalz
    ContributorAuthor

    Ryan Cavanaugh (@RyanCavanaugh) Indeed. My attempt was #25051, since we know that control flow analysis works when you manually unroll the union into a series of type-guarded clauses, and wouldn't it be nice if we could just tell the compiler to pretend that we did that unrolling?

    Jack Williams (@jack-williams) I think existential-like types are a reasonable workaround, if you can give up on UnionRecord entirely and use only TRecord<K> for an inferred K. Anyone who really needs to accept parameters of the full union type would still need an unsafe type assertion (e.g., record as TRecord<Kinds>) though.

  4. jcalz commented on Apr 8, 2019

    @jcalz
    ContributorAuthor
  5. jcalz commented on May 17, 2019

    @jcalz
    ContributorAuthor
  6. jcalz commented on Jun 1, 2019

    @jcalz
    ContributorAuthor

    Another SO question where this is the underlying issue (well, it doesn't come as a record type, but could be rephrased as one fairly easily)

  7. rubenpieters commented on Jun 11, 2019

    @rubenpieters

    Jack Williams (@jack-williams) Is that really an encoding of existential types? It exhibits the problem Ryan Cavanaugh (@RyanCavanaugh) mentioned:

    type Kinds = "n" | "s" | "b";
    type Reify<K extends Kinds> = K extends "n" ? number : K extends "s" ? string : K extends "b" ? boolean : never;
    type TRecord<K extends Kinds> = { kind: K, v: Reify<K>, f: (v: Reify<K>) => void}
    
    function processRecord<K extends Kinds>(record: TRecord<K>, record2: TRecord<K>) {
      record.f(record2.v); // oops
    }

    I believe the proper encoding to be something like the following:

    type Kinds = "n" | "s" | "b";
    type Reify<K extends Kinds> = K extends "n" ? number : K extends "s" ? string : K extends "b" ? boolean : never;
    type TRecord<K extends Kinds> = { kind: K, v: Reify<K>, f: (v: Reify<K>) => void };
    type RecordCont = <R>(cont: <K extends Kinds>(r: TRecord<K>) => R) => R;
    
    function processRecord(record: RecordCont) {
        record(r => r.f(record(r => r.v))); // typescript does not allow this though
    }
    
    const val: RecordCont = cont => {
        return cont({ kind: "n", v: 1, f: (x: number) => { } });
    }
    processRecord(val);

    Which typescript doesn't actually allow. If we use type Reify = { "n": number, "s": string, "b": boolean };, then it seemed to work before 3.5, however #30769 makes it not compile anymore. Maybe it can be seen as another use case for #30284 ?

    Ryan Cavanaugh (@RyanCavanaugh) why an entirely new concept? What is wrong with existential types?

  8. jack-williams commented on Jun 11, 2019

    @jack-williams
    Collaborator

    rubenpieters Yes, if you only care about having the existential within the body of the function, which is what you want here: ∀x(P(x) → R) ≡ (∃xP(x) → R). The continuation you pass in your encoding is exactly equivalent to the original function:

    function processRecord<K extends Kinds>(record: TRecord<K>) {
      record.f(record.v);
    }
    
    // Renamed RecordCond -> ExistsTRecord
    function processRecordExt(ext: ExistsTRecord) {
        return ext(processRecord);
    }

    The reason your example exhibits the same problem is because you are using the existential variable twice; record and record2 should have distinct type variables as there is no reason for them to be the same.

    function processRecord<K1 extends Kinds, K2 extends Kinds>(
        record: TRecord<K1>,
        record2: TRecord<K2>
    ) {
      record.f(record2.v); // error
    }
  9. rubenpieters commented on Jun 11, 2019

    @rubenpieters

    Yes, my bad, you are right. Also, I probably should have written processRecord with record(r => r.f(r.v));. The original was a leftover from when I was experimenting with two record parameters.

  10. jcalz commented on Jun 27, 2019

    @jcalz
    ContributorAuthor
  11. jcalz commented on Nov 28, 2019

    @jcalz
    ContributorAuthor
  12. jcalz commented on Feb 5, 2020

    @jcalz
    ContributorAuthor
  13. jcalz commented on Feb 7, 2020

    @jcalz
    ContributorAuthor
  14. 45 remaining items

  15. ahejlsberg commented on Jan 9, 2022

    @ahejlsberg
    Member

    Joe Calzaretta (@jcalz) Regarding the first issue, we aren't fully exploring the possible constraints of certain indexed access types as describe here. That's simply an issue we should fix.

    The situation is more complex in the second issue. Ideally you'd be able to type it like this:

    const methods = {
        a(value: number) {},
        b(value: string) {}
    };
    
    type MethodAndArg<K extends keyof typeof methods> = { [P in K]: {
        method: P;
        arg: Parameters<(typeof methods)[P]>[0]
    }}[K];
    
    function callMethodWithArg<K extends keyof typeof methods>(methodAndArg: MethodAndArg<K>) {
        const m = methods[methodAndArg.method];  // type (typeof methods)[K]
        m(methodAndArg.arg);  // Error, but ideally would require argument of type Parameters<(typeof methods)[K]>[0]
    }

    For this to work, when checking the function call m(methodAndArg.arg), we'd need to allow the argument type to be Parameters<(typeof methods)[K]>[0], but we don't reason about higher-order function calls to that extent. So, the best we can do is what you've already suggested in the SO issue.

    Note, though, that one advantage of the ArgMap explicitly typed version is that the parameters in methods are contextually typed and don't need type annotations:

    type ArgMap = { a: number, b: string };
    type Methods = { [K in keyof ArgMap]: (value: ArgMap[K]) => void }
    
    const methods: Methods = {
        a: value => { /* value has type number */ },
        b: value => { /* value has type string */ }
    };

    At least this reduces the redundancy a bit.

    BTW, thanks for your tireless exploration of this issue!

  16. jcalz commented on Feb 2, 2022

    @jcalz
    ContributorAuthor

    Trying to use this to answer this Stack Overflow question where the offending code is inside an async for loop:

    async function doStuffInAllModules() {
        const modules = Object.keys(moduleReadAndUpdate) as Array<keyof ItemTypePerModule>
        for (const mod of modules) {
            const item = await moduleReadAndUpdate[mod].read()
            await moduleReadAndUpdate[mod].update(item) // <-- error!
        }
    }

    Because the solution here uses generics constrained to the union instead of the union directly, the body of the loop would need to be refactored to a generic callback (right)? And then async stuff is all bleargggh:

    async function doStuffInAllModules() {
        const modules = Object.keys(moduleReadAndUpdate) as Array<keyof ItemTypePerModule>
        await modules.reduce(<K extends keyof ItemTypePerModule>(acc: Promise<void>, mod: K) => acc.then(() =>
            moduleReadAndUpdate[mod].read().then(
                item => moduleReadAndUpdate[mod].update(item) // okay 
            )
        ), Promise.resolve());
    }

    Looks like more trouble than it's worth. I wonder if there's anything nicer here.

    Playground link

  17. colin-alexa commented on Mar 28, 2022

    @colin-alexa

    Anders Hejlsberg (@ahejlsberg) Joe Calzaretta (@jcalz) I spent some time exploring ways to abstract the pattern you've developed here to use in more conventional code with discriminated unions, ideally without needing to manually produce your mapped type.

    I've created a typescript playground with a class that wraps this approach.

    type ValueOf<T> = T[keyof T];
    
    /** 
     * note that the discriminant key must be statically known from here on
     * which is somewhat onerous but I think it's common within a single codebase
     * to use a consistent pattern for keys of discriminated unions
     * 
     * here I have used "type", but of course you can substitute whatever you like
     */
    type ArgMap<I extends { type: string }, D extends I["type"] = I["type"]> = 
        { [K in D]: Extract<I, { ["type"]: K }> };
    
    class Multimethod<
        InputUnionT extends { type: string },
        ReturnT,
        DispatchableT extends InputUnionT["type"],
    > {
        constructor(
            private dispatchTable: {
                [K in keyof ArgMap<InputUnionT, DispatchableT>]:
                    (value: ArgMap<InputUnionT, DispatchableT>[K]) => ReturnT;
            }
        ) {}
    
        private callWithMethodAndArgs<
            K extends keyof ArgMap<InputUnionT, DispatchableT> = keyof ArgMap<InputUnionT, DispatchableT>
        >(
            op: {
                [P in K]: { method: P, arg: ArgMap<InputUnionT, DispatchableT>[P] };
            }[K]
        ) {
            return this.dispatchTable[op.method](op.arg);
        }
    
        private callWithArgMap(
            data: ValueOf<ArgMap<InputUnionT, DispatchableT>>
        ) {
            return this.callWithMethodAndArgs({ method: data.type, arg: data });
        }
    
        public canDispatch(data: InputUnionT): data is ValueOf<ArgMap<InputUnionT, DispatchableT>> {
            return data["type"] in this.dispatchTable;
        }
    
        public call(data: InputUnionT) {
            if (this.canDispatch(data)) {
                return this.callWithArgMap(data);
            } else {
                return;
            }
        }
    }
    
    type Article = { type: "article", headline: string };
    type User = { type: "user", name: string };
    type Photo = { type: "photo", x: number, y: number };
    type ArgUnion = Article | User | Photo;
    
    let dispatchTable = {
        article(value: Article) { },
        user(value: User) { }
    }
    
    let dispatcher = new Multimethod<ArgUnion, void, keyof typeof dispatchTable>(dispatchTable);
    
    function save(data: ArgUnion) {
        dispatcher.call(data);
    }

    It's not necessarily practical for production use when you can just cast your functions to the correct type at the callsite, but it does provide correct and useful type inferences and guards against type errors :)

  18. Harpush commented on Jul 15, 2022

    @Harpush

    Anders Hejlsberg (@ahejlsberg) I might be missing something - but shouldn't that work?

    enum Type {
      A = 'a',
      B = 'b'
    }
    
    interface One {
      type: Type.A;
      x: number;
    }
    
    interface Two {
      type: Type.B;
      y: string;
    }
    
    type Both = One | Two;
    
    type GetByType<T extends Type> = Extract<Both, {type: T}>;
    
    const mapper: {[P in Type]: (val: GetByType<P>) => boolean} = {/* ... */};
    
    // Doesn't work :(
    const getIt = <T extends Both>(b: T) => mapper[b.type](b);
    // Works?
    const getIt2 = <T extends Type>(b: T, c: GetByType<T>) => mapper[b](c);
    // Doesn't work :(
    const getIt3 = <T extends Type>(b: GetByType<T>) => mapper[b.type](b);

    Playground link

  19. jaidetree commented on May 31, 2023

    @jaidetree
    type Action = 
      | { type: 'init', query: string }
      | { type: 'sync', id: string }
      | { type: 'update', name: string, value: string }
    
    type HandlerMap = {
      [K in Action['type']]: (action: Extract<Action, { type: K }>) => void;
    }   
    
    const handlerMap: HandlerMap = {
      init (action) {},
      sync (action) {},
      update (action) {},
    }
    
    function dispatch (action: Action) {
      const handlerFn = handlerMap[action.type]
      return handlerFn(action)
                    // ^ Argument of type 'Action' is not assignable to parameter of type 'never'.
                    //   The intersection '{ type: "init"; query: string; } & { type: "sync"; 
                    //   id: string; } & { type: "update"; name: string; value: string; }' 
                    //   was reduced to 'never' because property 'type' has conflicting types 
                    //   in some constituents.
                    //   Type '{ type: "init"; query: string; }' is not assignable to type 
                    //   'never'.(2345)
    }
    
    console.log(
      dispatch({ type: 'init', query: "Example query"})
    )

    View on Interactive TypeScript Playground

    Is this that same problem? Given we're on TypeScript v5 now, is there a definitive solution, a solution in the pipeline, or an accepted workaround for this issue? Been poking around those related issues but some of them are from a few years and major versions back.

  20. jacoscaz commented on Oct 12, 2023

    @jacoscaz

    Just stumbled into this and spent quite a lot of time on it before realizing I had incurred in a limitation of the TS compiler.

    type OptionOne = {
      kind: "one";
    };
    
    type OptionTwo = {
      kind: "two";
    };
    
    type Options = OptionOne | OptionTwo;
    
    type OptionHandlers = {
      [Key in Options['kind']]: (option: Extract<Options, { kind: Key }>) => string;
    }
    
    const optionHandlers: OptionHandlers = {
      "one": (option: OptionOne) => "foo",
      "two": (option: OptionTwo) => "bar",
    };
    
    const handleOption = (option: Options): string => {
      return optionHandlers[option.kind](option);
    };

    The return line in handleOption() produces the following TS error:

    Argument of type 'Options' is not assignable to parameter of type 'never'.
      The intersection 'OptionOne & OptionTwo' was reduced to 'never' because property 'kind' has conflicting types in some constituents.
        Type 'OptionOne' is not assignable to type 'never'
    

    This specific limitation of the TS compiler induces one to favor switch statements over handler maps. This doesn't appear to be an issue in modern JS runtimes but used to result in significant performance deltas in older runtimes. As of today there doesn't seem to be a significant difference between the two approaches (tested with Deno v1.36.1, Node v18.17.0 and Bun v1.0.0).

  21. jedwards1211 commented on Nov 10, 2023

    @jedwards1211

    Joe Calzaretta (@jcalz) naively, I'm imagining some syntax for telling the compiler to typecheck/compute the type of record.f(record.v) on each branch of record's type separately, and then union those types, like record.f(record.v) for branch of record, it's just hard to think of a concise syntax that's obvious. But basically something like <expr> for branch of <identifier>.

  22. jcalz commented on Nov 10, 2023

    @jcalz
    ContributorAuthor

    Andy Edwards (@jedwards1211) Like #25051 (which was closed as a duplicate 🤷‍♂️)

  23. jedwards1211 commented on Nov 10, 2023

    @jedwards1211

    Why yes...I agree with you that should not have been closed as a duplicate

  24. devanshj commented on Jan 24, 2026

    @devanshj

    I know this issue is "fixed" by #47109 but there are still a number of stackoverflow links above that remain unsolved... and a small subset of those unsolved question can be solved by existential types. For example #30581 (comment)...

    Image

    I have a basic experimental PR open for existential types microsoft/typescript-go#2434 in case someone wants to play with it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Awaiting More FeedbackThis means we'd like to hear from more people who would be helped by this featureSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions