-
Notifications
You must be signed in to change notification settings - Fork 12.5k
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
Wishlist: support for correlated union types #30581
Comments
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) |
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
} |
@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 I think existential-like types are a reasonable workaround, if you can give up on |
Another SO question where this is the underlying issue: |
Another SO question where this is the underlying issue: |
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) |
@jack-williams Is that really an encoding of existential types? It exhibits the problem @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 @RyanCavanaugh why an entirely new concept? What is wrong with existential types? |
@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 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 |
Another one for the pile: |
And here's another: |
Wow, these keep coming: |
And another one! |
jumping on the train: |
I'm also encountering a need for this feature or something that fixes the same issue. |
I'm curious if this concept is successfully implemented in other languages TS could borrow it from? |
What I did was move the correlated type check to runtime. I think of correlated record types as the need for type narrowing on object properties. Extensibility, such as imports with side-effects, creates such a case. Extensibility adds entries to class prototypes and interfaces throughout different source files. Each property, now a union type, lacks narrowing to the runtime index at compile time. In my case, and what helped, runtime data specifies the type to check:
I start off with a I then build a map indexed by the string literal to runtime decoders:
A general decode function takes a I then build maps for each string literal to type specific functions:
Another general function here takes a Why extensibility? Permissions. A dev community can manage the "NumberRecord" and "StringRecord" functions, even submitting new ones. The core software which runs everything can be protected and closed to modifications. Example: Further discussion: |
It might be worth to mention, that this concept could be very useful and expanded to records that are homomorphic mapped types of an discriminated union - meaning they contain all its keys in a holistic way. Example:
Related issue: https://stackoverflow.com/questions/64092736/alternative-to-switch-statement-for-typescript-discriminated-union |
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. |
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
This specific limitation of the TS compiler induces one to favor |
@jcalz naively, I'm imagining some syntax for telling the compiler to typecheck/compute the type of |
@jedwards1211 Like #25051 (which was closed as a duplicate 🤷♂️) |
Why yes...I agree with you that should not have been closed as a duplicate |
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.f
is not callable (TS3.2 and below) or thatrecord.v
is not of typenever
(TS3.3 and up).Playground Link: 🔗
Discussion:
Consider the discriminated union
UnionRecord
above. 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.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 wantnever
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:
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
The text was updated successfully, but these errors were encountered: