Description
Search Terms
distributive control flow anaylsis; correlated types; union call signatures; conditional types
Suggestion: Opt-in distributive control flow analysis
Provide a way to tell the type checker to evaluate a block of code (or expression) multiple times: once for each possible narrowing of a specified value of a union type to one of its constituents. The block (or expression) should type-check successfully if (and only if) each of these evaluations does. Any values or expressions whose types are computed or affected by any of these evaluations should become the union of that computed or affected by the evaluations... that is, it should be distributive.
This allows some JavaScript patterns that currently don't work in TypeScript.
This suggestion would introduce new syntax at the type system level... preferably something which would currently cause a compiler error so it's guaranteed not to break existing code. I propose the syntax type switch(x)
and as if switch(x)
below, just to have something concrete to use... better suggestions are welcome.
Use Cases
This would ease the pain of:
- using "correlated" or "entangled" records in the absence of existential types Existential type? #14466
- calling overloaded functions on union types Support overload resolution with type union arguments #14107 or Call signatures of union types #7294 without requiring the difficult general solution
Currently these can only be addressed either by using unsafe type assertions, or by duplicating code to walk the compiler through the different possible cases.
Examples
A "correlated" or "entangled" record type
Consider the following discriminated union UnionRecord
:
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;
Note that each constituent of UnionRecord
is assignable to the following type for some type T
:
type GenericRecord<T> = { kind: string, v: T, f: (v: T) => void };
This means that, for any possible value record
of type UnionRecord
, one should be able to safely call record.f(record.v)
. But without direct support for existential types in TypeScript, it isn't possible to convert a UnionRecord
value to an exists T. GenericRecord<T>
. And thus it is impossible to safely express this in the type system:
function processRecord(record: UnionRecord) {
record.f(record.v); // error, record.f looks like a union of functions; can't call those
}
Right now, the ways to convince TypeScript that this is okay are either to use an unsafe assertion:
function processRecord(record: UnionRecord) {
type T = string | number | boolean; // or whatever
const genericRecord = record as GenericRecord<T>; // unsafe
genericRecord.f(genericRecord.v);
genericRecord.f("hmm"); // boom?
}
or to explicitly and unnecessarily narrow record
to each possible constituent of UnionRecord
ourselves:
const guard = <K extends UnionRecord['kind']>(
k: K, x: UnionRecord): x is Extract<UnionRecord, {kind: K}> => x.kind === k;
const assertNever = (x: never): never => { throw new Error(); }
function processRecord(record: UnionRecord) {
if (guard("n", record)) {
record.f(record.v); // okay
} else if (guard("s", record)) {
record.f(record.v); // okay
} else if (guard("b", record)) {
record.f(record.v); // okay
} else assertNever(record);
}
^ This is not just redundant, but brittle: add another GenericRecord<T>
-compatible constituent to the UnionRecord
, and it breaks.
While it would be nice if the type checker automatically detected and addressed this situation, it would probably be unreasonable to expect it to aggressively distribute its control flow analysis over every union-typed value. I can only imagine the combinatoric nightmare of performance problems that would cause. But what if you could just tell the type checker to do the distributive narrowing for a particular, specified value? As in:
function processRecord(record: UnionRecord) {
type switch (record) { // new syntax here
record.f(record.v); // okay
}
}
The idea is that type switch(val)
tells the type checker to iterate over the union constituents of typeof val
, similarly to the way distributive conditional types iterate over the naked type parameter's union constituents. Since the code in the block has no errors in each such narrowing, it has no errors overall. (Note that any performance problem caused by this should be comparable to that of compiling the "brittle" code above)
That is: if a value x
is of type A | B
, then type switch(x)
should iterate over A
and B
, narrowing x
to each in turn. (Also, I think that if a value t
is of a generic type T extends U | V
, then type switch(t)
should iterate over U
and V
.)
Additionally, any value whose type would normally be narrowed by control flow analysis in the block should end up as the union of all such narrowed types, which is very much like distributive conditional types. That is: if narrowing x
to A
results in y
being narrowed to C
, and if narrowing x
to B
results in y
being narrowed to D
, then type switch(x) {...y...}
should result in y
being narrowed to C | D
.
Another motivating example which shows the distributive union expression result:
Calling overloaded functions on union types
Imagine an overloaded function and the following oft-reported issue:
declare function overloaded(x: string): number;
declare function overloaded(x: number): boolean;
declare const x: string | number;
const y = overloaded(x); // expected number | boolean, got error
Before TypeScript 2.8 the solutions were either to add a third redundant-information overload which accepted string | number
and returned number | boolean
:
declare function overloaded(x: string | number): number | boolean; // add this
or to narrow x
manually in a redundant way:
const y = (typeof x === 'string') ? overloaded(x) : overloaded(x);
// y is number | boolean;
Since TypeScript 2.8 one could replace the three overloaded signatures with a single overloaded signature using conditional types:
// replace with this
declare function overloaded<T extends string | number>(x: T): T extends string ? number : boolean;
which, when the argument is a union, distributes over its types to produce a result of number | boolean
.
The suggestion here would allow us to gain this behavior of distributive conditional types acting at the control flow level. Using the type switch(x)
code block syntax for a single expression is a bit messy:
let y: string | number | boolean; // something wide
type switch(x) {
y = overloaded(x); // okay
}
y; // narrowed to number | boolean;
If we could introduce a way to represent distributive control flow for an expression instead of as a statement, it would be the simpler
const y = (overloaded(x) as if switch(x)); // y is number | boolean
where the as if switch(x)
is saying "evaluate the type of the preceding expression by distributing over the union constituents of typeof x
".
Whew, that's it. Your thoughts? Thanks for reading!
Checklist
My suggestion meets these guidelines:
- This wouldn't be a breaking change in existing TypeScript / JavaScript code (new syntax is currently an error)
- This wouldn't change the runtime behavior of existing JavaScript code (has no effect on emitted js)
- This could be implemented without emitting different JS based on the types of the expressions (has no effect on emitted js)
- This isn't a runtime feature (e.g. new expression-level syntax) (has no effect on emitted js)