Repository navigation
Wishlist: support for correlated union types #30581
Description
Activity
jack-williams commented
on Mar 25, 2019 CollaboratorMore actionsPresumably 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)
Reacted by Joe Calzaretta, Jeswin, Dorian Thiessen and Peter FlynnRyanCavanaugh commented
on Mar 25, 2019 MemberMore actionsWe'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 }
Reacted by Joe Calzaretta, Sean Vieira, Aleksey Levenstein, Bnaya Peretz, Konrad Madej, Omri Luzon, Zzzen, interphx, Haroen Viaene, Władysław Raczek and 2 more- addedSuggestionAn idea for TypeScriptAn idea for TypeScriptAwaiting More FeedbackThis means we'd like to hear from more people who would be helped by this featureThis means we'd like to hear from more people who would be helped by this feature
on Mar 25, 2019 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
UnionRecordentirely and use onlyTRecord<K>for an inferredK. 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.Reacted by Aleksandras Andrejevas and LogosAcUmbraAnother SO question where this is the underlying issue:
Reacted by Titian Cernicova-Dragomir, Ryan Cavanaugh, phi-fell, Aleksander Sorokin and Aleksandras AndrejevasAnother SO question where this is the underlying issue:
Reacted by Titian Cernicova-Dragomir, Ryan Cavanaugh, phi-fell, Arnau Sanchez Sala, Aleksander Sorokin, Max Cherniavskyi and Aleksandras AndrejevasAnother 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)
Reacted by Titian Cernicova-Dragomir, phi-fell, Maximilian Mairinger and Aleksandras AndrejevasJack 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?
jack-williams commented
on Jun 11, 2019 CollaboratorMore actionsrubenpieters 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
exactlyequivalent 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 }
Yes, my bad, you are right. Also, I probably should have written
processRecordwithrecord(r => r.f(r.v));. The original was a leftover from when I was experimenting with two record parameters.Another one for the pile:
Reacted by Aleksandras Andrejevas and Peter FlynnAnd here's another:
Reacted by Titian Cernicova-Dragomir, Nikolay Rusev, Bnaya Peretz, phi-fell, Kristian Roebuck and Aleksandras Andrejevas- Reacted by Bnaya Peretz, Omri Luzon and Aleksandras Andrejevas
- Reacted by Bnaya Peretz, soul-codes, Omri Luzon and Aleksandras Andrejevas
45 remaining items
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 beParameters<(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
ArgMapexplicitly typed version is that the parameters inmethodsare 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!
Reacted by Joe Calzaretta, Karl Horky, Toni Villena, Peter Juras, Sean Vieira, Daniel Popeski, Arnau Sanchez Sala, Gabriel Rocha de Oliveira, Roger Schönbächler, Vadim and 2 moreTrying 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
asyncstuff 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.
Reacted by Paweł ZmarzłyAnders 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 :)
Reacted by Sean VieiraReacted by Sean VieiraAnders 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);
jaidetree commented
on May 31, 2023 More actionstype 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.
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
returnline inhandleOption()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
switchstatements 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).Reacted by szszokeJoe 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 ofrecord's type separately, and then union those types, likerecord.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>.Andy Edwards (@jedwards1211) Like #25051 (which was closed as a duplicate 🤷♂️)
Why yes...I agree with you that should not have been closed as a duplicate
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)...
I have a basic experimental PR open for existential types microsoft/typescript-go#2434 in case someone wants to play with it
Reacted by Niki and Ellie- added a commit that references this issue
on Jun 14, 2026 - added 3 commits that reference this issue
on Jul 22, 2026
TypeScript Version: 3.4.0-dev.20190323
Search Terms
correlated union record types
Code
Expected behavior:
The implementation of
processRecord()code compiles without errorActual behavior:
The call of
record.f(record.v)complains either thatrecord.fis not callable (TS3.2 and below) or thatrecord.vis not of typenever(TS3.3 and up).Playground Link: 🔗
Discussion:
Consider the discriminated union
UnionRecordabove. How can we convince the compiler that the implementation ofprocessRecord()is type safe?I made a previous suggestion (#25051) to deal with this, but it was closed as a duplicate of #7294, since
record.fwas 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 wantneverhere?")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:
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