Skip to content

Suggestion: Opt-in distributive control flow analysis #25051

Closed
@jcalz

Description

@jcalz

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:

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    DuplicateAn existing issue was already created

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions