microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.33k stars 12.54k forks source link

Generic enumerated type parameter narrowing (conditional types) #24085

Open krisdages opened 6 years ago

krisdages commented 6 years ago

Search Terms

conditional type inference enum enumerated narrowing branching generic parameter type guard

Suggestion

Improve inference / narrowing for a generic type parameter and a related conditional type. I saw another closed-wontfix issue requesting generic parameter type guards, but a type guard should not be necessary for this case, since the possible values for the generic are enumerated.

Use Cases

(Names have been changed and simplified) I have a method that takes a KeyType (enumerated) and a KeyValue with type conditionally based on the enumerated KeyType.
Depending on the KeyType value, the code calls method(s) specific to that type.

The TS compiler is unable to tell that after I have checked the enumerated KeyType, the type of the KeyValue (string, number, etc) is known and should be able to be passed to a function that only accepts that specific KeyValue type.

Examples

const enum TypeEnum {
    String = "string",
    Number = "number",
    Tuple = "tuple"
}
// The issue also occurs with
// type TypeEnum = "string" | "number" | "tuple"

interface KeyTuple { key1: string; key2: number; }

type KeyForTypeEnum<T extends TypeEnum> 
    = T extends TypeEnum.String ? string
    : T extends TypeEnum.Number ? number
    : T extends TypeEnum.Tuple ? KeyTuple
    : never;

class DoSomethingWithKeys { 
    doSomethingSwitch<TType extends TypeEnum>(type: TType, key: KeyForTypeEnum<TType>) {
        switch (type) {
            case TypeEnum.String: {
                this.doSomethingWithString(key);
                break;
            }
            case TypeEnum.Number: {
                this.doSomethingWithNumber(key);
                break;
            }
            case TypeEnum.Tuple: {
                this.doSomethingWithTuple(key);
                break;
            }
        }
    }

    doSomethingIf<TType extends TypeEnum>(type: TType, key: KeyForTypeEnum<TType>) {
        if (type === TypeEnum.String) {
            this.doSomethingWithString(key);
        }
        else if (type === TypeEnum.Number) {
            this.doSomethingWithNumber(key);
        }
        else if (type === TypeEnum.Tuple) {
            this.doSomethingWithTuple(key);
        }
    }   

    private doSomethingWithString(key: string) {

    }

    private doSomethingWithNumber(key: number) {

    }

    private doSomethingWithTuple(key: KeyTuple) {

    }
}

This should compile without errors if TS was able to tell that the switch statements or equality checks limited the possible type of the other property.

I lose a lot of the benefits of TS if I have to cast the value to something else. especially if I have to cast as any as KeyForTypeEnum<TType> as has happened in my current codebase.

If I'm doing something wrong or if there's already a way to handle this, please let me know.

Checklist

My suggestion meets these guidelines: [X] This wouldn't be a breaking change in existing TypeScript / JavaScript code [X] This wouldn't change the runtime behavior of existing JavaScript code [X] This could be implemented without emitting different JS based on the types of the expressions [X] This isn't a runtime feature (e.g. new expression-level syntax)

krryan commented 6 years ago

Seems related to #21879, and possibly #20375, which are pretty high priorities in my mind, too. Absolutely agreed that we really want something like this to be possible. A common use-case in our code is mapping functions, that take a union and map each possible value in the union to the corresponding value in another union. As an example, a function that maps '1' | '2' | '3' to 1 | 2 | 3. You can write a conditional type for this with

type CorrespondingNumeralOf<Char extends '1' | '2' | '3'> =
    Char extends '1' ? 1 :
    Char extends '2' ? 2 :
    Char extends '3' ? 3 :
    never;

function mapCharToNumeral<Char extends '1' | '2' | '3'>(char: Char): CorrespondingNumeralOf<Char> {
    switch (char) {
        case '1': return 1;
        case '2': return 2;
        case '3': return 3;
        default: impossible(char);
    }
}

/**
 * Ensures complete case-coverage since it mandates a never value.
 * In our implementation, throws an error noting what took on an impossible value,
 * in case of discrepancies between compile-time expectations and run-time reality.
 */
declare function impossible(_: never): never;

But this runs into a couple of problems: TS won't narrow Char, TS won't recognize 1 as CorrespondingNumeralOf<Char> in case '1'.

There really ought to be a type-safe way to write these kinds of functions, seeing as there is a type-safe way to describe them.

(And in case anyone thinks function overloads are a solution here, keep in mind that those aren't really any more type-safe than just using casting here, and in any event, those lack the ability to handle arbitrary subsets of the first union and map them to the corresponding subset of the second union. Not too bad when looking at three cases, but I recently wrote something very much like this that handled 28 cases.)

JakeTunaley commented 6 years ago

This would be really useful for typing addEventListener patterns. Here's a sample of real-world code where I ran into this issue:

type FileEvent = 'read' | 'write' | 'delete';

type FileEventListener<T extends FileEvent, R, W> =
    T extends 'read' ? (file: File<R, W>, data: R) => void :
    T extends 'write' ? (file: File<R, W>, data: W) => void :
    T extends 'delete' ? (file: File<R, W>) => void :
    never;

function isReadEvent (evt: FileEvent): evt is 'read' {
    return evt === 'read';
}

class File<R, W> {
    // Implementation omitted for brevity

    public addEventListener<T extends FileEvent> (evt: T, listener: FileEventListener<T, R, W>): void {
        if (evt === 'read') {
            // evt: T extends EventType (expecting "read")
            // listener: EventListener<T, R, W> (expecting (data: R) => void)
        }
        if (isReadEvent(evt)) {
            // evt: T & "read" (expecting "read")
            // listener: EventListener<T, R, W> (expecing "write")
        }
    }
}
mattmccutchen commented 6 years ago

After reading #27808, I realized that the original example is unsound. A caller can just do:

new DoSomethingWithKeys().doSomethingSwitch<TypeEnum>(TypeEnum.String, 42);

We'd need to express somehow that TType must be (in this scenario) a singleton type. Compare to #25879.

jack-williams commented 6 years ago

I remember thinking about this when a thread regarding multiple use type parameters came up. Perhaps something like: uniform types. Types inhabited only by values that behave uniformly under operations such as typeof.

We write T*, where * is a predicate on types that holds when:

So string* is valid, so is number*, but not string | number.

Example:

declare function assert<T*>(x: T, y: T): boolean;
assert(true,false) // ok
assert(1,false) // not ok

declare function assertWide<T>(x: T, y: T): boolean;
assertWide(true,false) // ok
assertWide(1,false) // ok, T = number | boolean

It might even be possible to specify the operation, so we could include equality for enums. We write T=, such that

new DoSomethingWithKeys().doSomethingSwitch< TypeEnum.String= >(TypeEnum.String, "foo"); //ok

jack-williams commented 5 years ago

I have a very experimental PR that is capable of type-checking the original example: #30284

tadhgmister commented 5 years ago

Can get around this by using an interface to map strings to corresponding types instead of a ternary chain. Not sure why generic type that is constrained by a union doesn't narrow though, but using a temporary variable can get around that as well:

interface Numerals {
    '1': 1;   '2': 2;   '3': 3;
}
type CorrespondingNumeralOf<Char extends keyof Numerals> = Numerals[Char];

function mapCharToNumeral<Char extends keyof Numerals>(char: Char): CorrespondingNumeralOf<Char> {
    const c: keyof Numerals = char;
    switch (c) {
        case '1': return 1;
        case '2': return 2;
        case '3': return 3;
        default: impossible(c);
    }
}

For the original case it's a little more complicated, to narrow one variable type needs a way to relate to the type of the other, which means we have to have a variable that actually implements this pseudo interface:

const enum TypeEnum {
    String = 'string', Number = 'number', Tuple = 'tuple'
}
interface KeyTuple { key1: string; key2: number; }

interface Mapping {
    // use this for lookup instead of ternary chain.
    [TypeEnum.String]: string;
    [TypeEnum.Number]: number;
    [TypeEnum.Tuple]: KeyTuple;
}
type KeyForTypeEnum<T extends TypeEnum> = Mapping[T];

class DoSomethingWithKeys {
    public doSomethingSwitch<TType extends TypeEnum>(type: TType, key: KeyForTypeEnum<TType>) {
        // will have one of the fields viable, this way when we get a value from part
        // it will be type checked
        const part: Partial<Mapping> = { [type]: key };
        const typeAlias: TypeEnum = type;
        switch (typeAlias) {
            case TypeEnum.String: {
                type;
                this.doSomethingWithString(part[typeAlias]);
                break;
            }
            case TypeEnum.Number: {
                this.doSomethingWithNumber(part[typeAlias]);
                break;
            }
            case TypeEnum.Tuple: {
                this.doSomethingWithTuple(part[typeAlias]);
                break;
            }
            default: {
                impossible(typeAlias);
            }
        }
    }
    private doSomethingWithString(key: string) {}
    private doSomethingWithNumber(key: number) {}
    private doSomethingWithTuple(key: KeyTuple) {}
}
tadhgmister commented 5 years ago

Generalized function paired to turn this form of mapping into a data structure that works well in a switch statement and works in strict null checks, along with working example of @JakeTunaley use case.

type Boxed<Mapping> = { [K in keyof Mapping]: { key: K; value: Mapping[K] } }[keyof Mapping];
/**
 * boxes a key and corresponding value from a mapping and returns {key: , value: } structure
 * the type of return value is setup so that a switch over the key field will guard type of value
 * It is intentionally not checked that key and value actually correspond to each other so that
 * this can return a union of possible pairings, intended to be put in a switch statement over the key field.
 */
function paired<Mapping>(key: keyof Mapping, value: Mapping[keyof Mapping]) {
    return { key, value } as Boxed<Mapping>;
}

interface FileEventListenerSignatures<R, W> {
    read: (file: MyFile<R, W>, data: R) => void;
    write: (file: MyFile<R, W>, data: W) => void;
    delete: (file: MyFile<R, W>) => void;
}
type FileEvent = keyof FileEventListenerSignatures<any, any>;

type FileEventListener<T extends FileEvent, R, W> = FileEventListenerSignatures<R, W>[T];

function isReadEvent(evt: FileEvent): evt is 'read' {
    return evt === 'read';
}

class MyFile<R, W> {
    // Implementation omitted for brevity
    public addEventListener<T extends FileEvent>(evt: T, listener: FileEventListener<T, R, W>): void {
        let pair = paired<FileEventListenerSignatures<R, W>>(evt, listener);
        switch (pair.key) {
            case 'read': {
                pair.value; // (property) value: (file: MyFile<R, W>, data: R) => void
                break;
            }
            case 'write': {
                pair.value; // (property) value: (file: MyFile<R, W>, data: W) => void
                break;
            }
            case 'delete': {
                pair.value; // (property) value: (file: MyFile<R, W>) => void
                break;
            }
            default: {
                impossible(pair);
            }
        }
    }
}

declare function impossible(_: never): never;

I don't think with typescript it would be possible to fully match the relationship between 2 variables but it would be nice if instead of having a function like paired you could just do let pair = {evt, listener} and based on the generics involved it would correctly deduce the possible values of that structure, that would be great.

krisdages commented 5 years ago

The "extends oneof" syntax mentioned in one of the linked issues seems pretty interesting.

If <T extends oneof Union = Union> could work in general that could also eliminate an awkward pattern I'm having to use with constraints in interfaces:

interface _X<T extends Union> {
  a: AFor<T>;
  b: BFor<T>;
}

export type X<T extends Union = Union> = 
  T extends any ? _X<T> : never;

BTW, this doesn't fix the narrowing issue inside the body, but for validating the correspondence of the parameters, this seems to work, now that rest parameters can be inferred:

doSomething<T extends Union = Union>(
        ...[a, b]: T extends any ? [AFor<T>, BFor<T>] : never
    ): void {
   //...
}
Ranguna commented 4 years ago

@tadhgmister I'm sorry to say but your code doesn't compile.

Ranguna commented 4 years ago

Also adding my two cents: https://www.typescriptlang.org/play?ts=3.9.2#code/KYOwrgtgBAou0G8BQVVQIIBoBCmDCSAvkkgMYD2IAzgC5QCyAhgA7PABOUAvFMmlAG04kAHToAugC4oACkbsA5tNrsAliAUBKbgD4oAOUgAjDnMWbMKNEPgjsU2fKVR4J9tq56AhE8v8bongOZs5G5OQANsCMIB56AESqNADkVFDxUADUjooA-Ok07GDAGdLxAGaMEVQlFkQkFNR0pFURTKwc3FAAPAAqUMAAHjSgACZpANbAAJ7k5VA002xzDCxs7DoyU9PSvZhQTtIACvKMEMAj7FTdi8vz7esCveI6AgAM4prSAEoXYOwgXpLYA3YErB4cJ4vXRWVAQ9gCbbiEKaADcSCAA

Basically I'm trying to use a mapper of enum -> function, where each function receives the same number of arguments and their types and return values depend on the enum. I can't call the mapper from a function that accepts the key of the mapper and the value of the respective function. The compiler complains that the result of calling the mapper is not assignable to the expected return type (dependent on the enum passed) because TS creates a union of the return types instead of actually inferring them.

Workaround is to cast the the result of calling the mapper to any. Unfortunately you'll lose type safety this way, so you'll have to make sure you know what you're doing, which we can all admit that we never really know :+1:

tadhgmister commented 4 years ago

@Ranguna pretty sure it does: Playground Link. How were you trying to compile it?

Ranguna commented 4 years ago

@tadhgmister Sorry, I should've been more specific. I was talking about this code:

interface Numerals {
    '1': 1;   '2': 2;   '3': 3;
}
type CorrespondingNumeralOf<Char extends keyof Numerals> = Numerals[Char];

function mapCharToNumeral<Char extends keyof Numerals>(char: Char): CorrespondingNumeralOf<Char> {
    const c: keyof Numerals = char;
    switch (c) {
        case '1': return 1;
        case '2': return 2;
        case '3': return 3;
        default: impossible(c);
    }
}

(ignoring the missing impossible function)

tadhgmister commented 4 years ago

Oh I see, it looks like it worked the way I wanted it in version 3.3 but not in 3.5 or newer, not sure how I managed that since I thought I started using typescript when it was at 3.5.

Still doesn't change that your function example is something I have used in the past and my solution there doesn't extend to cases like that unfortunately.. 😕

Ranguna commented 4 years ago

Yeah, it'd be nice if I could find a way to make this work but I've spent the better part of the day yesterday trying to figure it out, alas I wasn't able to :( I'll just keep casting the result of the mapper to any until typescript can properly type these situations.

Thank you either way :+1:

erjiang commented 4 years ago

Just wanted to add a real-world example that I ran into (and I think falls under this bug):

import React = require("react");
// Artificial example of dealing with CSS properties in TS:
function getDefaultStyle<K extends keyof React.CSSProperties>(name: K): React.CSSProperties[K] {
  switch (name) {
    // Type '"20px"' is not assignable to type 'CSSProperties[K]'.
    //   Type '"20px"' is not assignable to type '"-moz-initial" | "inherit" | "initial" | "revert" | "unset" | undefined'. ts(2322)
    case "marginTop": return "20px";
    case "textAlign": return "left";
    case "backgroundColor": return "transparent";
    // this line typechecks because "unset" is valid for all K in React.CSSProperties[K]
    default: return "unset";
  }
}

Yes, this switch case could be expressed as a map in the simplest case, but not if there's programmatic logic involved.

jcalz commented 7 months ago

cross-linking #33014, which seems to be the main issue tracking this (even though this is older)