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

Generic enumerated type parameter narrowing (conditional types) #24085

Open
krisdages opened this issue May 13, 2018 · 16 comments
Open

Generic enumerated type parameter narrowing (conditional types) #24085

krisdages opened this issue May 13, 2018 · 16 comments
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript

Comments

@krisdages
Copy link

krisdages commented May 13, 2018

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
Copy link

krryan commented May 14, 2018

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

@mhegazy mhegazy added Suggestion An idea for TypeScript Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. labels May 14, 2018
@mhegazy mhegazy mentioned this issue May 16, 2018
4 tasks
@JakeTunaley
Copy link

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
Copy link
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.

@jack-williams
Copy link
Collaborator

jack-williams commented Oct 11, 2018

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

@jack-williams
Copy link
Collaborator

jack-williams commented Mar 10, 2019

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

@tadhgmister
Copy link

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
Copy link

tadhgmister commented Apr 7, 2019

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
Copy link
Author

krisdages commented Jun 4, 2019

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
Copy link

Ranguna commented May 16, 2020

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

@Ranguna
Copy link

Ranguna commented May 16, 2020

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 👍

@tadhgmister
Copy link

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

@Ranguna
Copy link

Ranguna commented May 17, 2020

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

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
Copy link

Ranguna commented May 17, 2020

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 👍

@erjiang
Copy link

erjiang commented Jul 24, 2020

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
Copy link
Contributor

jcalz commented May 3, 2024

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
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

10 participants