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

Wishlist: support for correlated union types #30581

Closed
jcalz opened this issue Mar 25, 2019 · 66 comments · Fixed by #47109
Closed

Wishlist: support for correlated union types #30581

jcalz opened this issue Mar 25, 2019 · 66 comments · Fixed by #47109
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@jcalz
Copy link
Contributor

jcalz commented Mar 25, 2019

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

@jack-williams
Copy link
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)

@RyanCavanaugh
Copy link
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
}

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Mar 25, 2019
@jcalz
Copy link
Contributor Author

jcalz commented Mar 26, 2019

@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 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.

@jcalz
Copy link
Contributor Author

jcalz commented Apr 8, 2019

Another SO question where this is the underlying issue:

@jcalz
Copy link
Contributor Author

jcalz commented May 17, 2019

Another SO question where this is the underlying issue:

@jcalz
Copy link
Contributor Author

jcalz commented Jun 1, 2019

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)

@rubenpieters
Copy link

@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 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 ?

@RyanCavanaugh why an entirely new concept? What is wrong with existential types?

@jack-williams
Copy link
Collaborator

jack-williams commented Jun 11, 2019

@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
}

@rubenpieters
Copy link

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.

@jcalz
Copy link
Contributor Author

jcalz commented Jun 27, 2019

Another one for the pile:

@jcalz
Copy link
Contributor Author

jcalz commented Nov 28, 2019

And here's another:

@jcalz
Copy link
Contributor Author

jcalz commented Feb 5, 2020

@jcalz
Copy link
Contributor Author

jcalz commented Feb 7, 2020

@jcalz
Copy link
Contributor Author

jcalz commented Feb 10, 2020

Wow, these keep coming:

@jcalz
Copy link
Contributor Author

jcalz commented Feb 24, 2020

And another one!

@DerGernTod
Copy link

jumping on the train:
How can I define a tuple array with different tuple types explicitly in typescript?

@phi-fell
Copy link

I'm also encountering a need for this feature or something that fixes the same issue.

@amakhrov
Copy link

I'm curious if this concept is successfully implemented in other languages TS could borrow it from?

@urgent
Copy link

urgent commented Sep 3, 2020

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:

[{type:"NumberRecord", value:3}, {type:"StringRecord", chars:"lorem ipsum"} ... ]

I start off with a Prop type. Prop holds every allowable runtime property, like value or chars.

I then build a map indexed by the string literal to runtime decoders:

{
  "NumberRecord":numberDecoder, 
  "StringRecord":stringDecoder
}

A general decode function takes a Prop argument, looks up the decoder, and performs the runtime decoding. One caveat, in io-ts, you need to widen from Prop to any just for the runtime check in strict mode.

I then build maps for each string literal to type specific functions:

{
  "NumberRecord":(props:Props) => numberOutput` ,
  "StringRecord":(props:Props) => stringOutput
}

Another general function here takes a Prop argument, looks up the function to run, and passes the Prop values. If you run this after the runtime decode, you know you are safe and that Prop narrowed to allowable arguments for the type specific function.

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:
https://repl.it/@urgent/runtime-fp-ot

Further discussion:
https://stackoverflow.com/a/63728886/11971788

@bela53
Copy link

bela53 commented Oct 1, 2020

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:

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

// object literal implements *all* keys of `UnionRecord`
const callbacks = {
    n: (n: number) => {},
    s: (v: string) => {},
    b: (b: boolean) => {},
} as const

function processRecord(record: UnionRecord, kind: UnionRecord["kind"]) {
    const fn = callbacks[kind] 
    // actual: ((n: number) => void) | ((s: string) => void) | ((b: boolean) => void)
    // expected: (val: number | string | boolean) => void

    const val = record.v // string | number | boolean
    fn(val) // errors, but should work
}

Code sample

Related issue: https://stackoverflow.com/questions/64092736/alternative-to-switch-statement-for-typescript-discriminated-union

@jcalz
Copy link
Contributor Author

jcalz commented Oct 21, 2020

@jcalz
Copy link
Contributor Author

jcalz commented Nov 18, 2020

@jcalz
Copy link
Contributor Author

jcalz commented Nov 24, 2020

@jcalz
Copy link
Contributor Author

jcalz commented Aug 5, 2022

@jaidetree
Copy link

jaidetree commented May 31, 2023

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.

@jacoscaz
Copy link

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).

@jedwards1211
Copy link

jedwards1211 commented Nov 10, 2023

@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>.

@jcalz
Copy link
Contributor Author

jcalz commented Nov 10, 2023

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

@jedwards1211
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.