Skip to content

Generic enumerated type parameter narrowing (conditional types) #24085

Open

Description

@krisdages

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)

Activity

  1. krryan commented on May 14, 2018

    @krryan

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

  2. added
    SuggestionAn idea for TypeScript
    Needs ProposalThis issue needs a plan that clarifies the finer details of how it could be implemented.
    on May 14, 2018
  3. JakeTunaley commented on Jul 23, 2018

    @JakeTunaley

    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")
            }
        }
    }
  4. mattmccutchen commented on Oct 11, 2018

    @mattmccutchen
    Contributor

    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.

  5. jack-williams commented on Oct 11, 2018

    @jack-williams
    Collaborator

    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:

    • for all x, y in T. typeof x === typeof y.

    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

    • for all x, y in T. x === y.
    new DoSomethingWithKeys().doSomethingSwitch< TypeEnum= >(TypeEnum.String, 42);
    // invalid, TypeEnum does not satisfy predicate =
    
    new DoSomethingWithKeys().doSomethingSwitch< TypeEnum.String= >(TypeEnum.String, "foo");
    //ok
    
  6. jack-williams commented on Mar 10, 2019

    @jack-williams
    Collaborator

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

  7. tadhgmister commented on Apr 7, 2019

    @tadhgmister

    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) {}
    }
  8. tadhgmister commented on Apr 7, 2019

    @tadhgmister

    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.

  9. krisdages commented on Jun 4, 2019

    @krisdages
    Author

    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 {
       //...
    }
  10. Ranguna commented on May 16, 2020

    @Ranguna

    Tadhg McDonald-Jensen (@tadhgmister) I'm sorry to say but your code doesn't compile.

  11. Ranguna commented on May 16, 2020

    @Ranguna

    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 👍

  12. tadhgmister commented on May 17, 2020

    @tadhgmister

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

  13. Ranguna commented on May 17, 2020

    @Ranguna

    Tadhg McDonald-Jensen (@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)

  14. tadhgmister commented on May 17, 2020

    @tadhgmister

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

  15. Ranguna commented on May 17, 2020

    @Ranguna

    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 👍

  16. erjiang commented on Jul 24, 2020

    @erjiang

    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.

  17. jcalz commented on May 3, 2024

    @jcalz
    Contributor

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs ProposalThis issue needs a plan that clarifies the finer details of how it could be implemented.SuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions