Skip to content

Regression when using indexed type depending on function argument as return type #31672

Closed
@AlCalzone

Description

@AlCalzone

TypeScript Version: 3.5.1

Search Terms:

Code

interface Foo {
	prop1: { value: number }[];
	prop2: string[];
}
function getFoo<T extends keyof Foo>(key: T): Foo[T] | undefined {
	if (key === "prop1") {
		return [{ value: 1 }]; // Error here
	}
}
const bar = getFoo("prop1"); // has correct type

Expected behavior:
No error. This used to work in 3.4.5

Actual behavior:

Type '{ value: number; }[]' is not assignable to type 'Foo[T]'.
  Type '{ value: number; }[]' is not assignable to type '{ value: number; }[] & string[]'.
    Type '{ value: number; }[]' is not assignable to type 'string[]'.
      Type '{ value: number; }' is not assignable to type 'string'.

Activity

fatcerberus

fatcerberus commented on May 30, 2019

@fatcerberus

I'm thinking this is probably a result of #30769.

AnyhowStep

AnyhowStep commented on May 30, 2019

@AnyhowStep
Contributor

I'm not sure if this is a bad thing at all. More inconvenient, for sure. But if you're sure it's correct, I think having an explicit type assertion (as T[K] or as unknown as T[K]) for unsound code is a good thing.

fatcerberus

fatcerberus commented on May 30, 2019

@fatcerberus

Yeah, the new behavior is in general much more sound.

In this particular case the code looks okay since the key === "prop1" check narrows things down. The trouble arises because the type guard only affects the parameter key itself; Foo[T] is in a generic context so the compiler treats it like T is as wide as possible, i.e. as its constraint keyof Foo.

Too bad there wasn’t a way to make the compiler narrow type parameters like we can narrow variable and property types...

RyanCavanaugh

RyanCavanaugh commented on May 31, 2019

@RyanCavanaugh
Member

This wasn't properly checked in 3.4:

interface Foo {
	prop1: { value: number }[];
	prop2: string[];
}
function getFoo<T extends keyof Foo>(key: T): Foo[T] | undefined {
  if (key === "prop1") {
        // Oops!
		return ["foo"];
	}
}
const bar = getFoo("prop1");
fatcerberus

fatcerberus commented on May 31, 2019

@fatcerberus

Yep, might be nice if there was a way to narrow whole type parameters based on type guards, but that kind of thing is probably really challenging to implement in practice. Particularly in cases where there's more than one inference site for a type.

RyanCavanaugh

RyanCavanaugh commented on May 31, 2019

@RyanCavanaugh
Member

You can't narrow a type parameter based on a value, because you can't know that the type parameter isn't wider than the value you saw:

interface Foo {
	prop1: { value: number }[];
	prop2: string[];
}
function getFoo<T extends keyof Foo>(key1: T, key2: T): Foo[T] | undefined {
  // checking key1 cannot tell you what key2 will be or what a valid return type is
  return null as any;
}
// T: "prop1" | "prop2"
const bar = getFoo("prop1", "prop2");
jack-williams

jack-williams commented on May 31, 2019

@jack-williams
Collaborator

@fatcerberus

Even limiting it to types with one inference site is insufficient because one site can produce multiple values: Eg. () => T or T[].

Validark

Validark commented on Jun 2, 2019

@Validark

@RyanCavanaugh Don't you think cases like this should work though? Is this unsound?

interface ConfigMap {
	Number: number;
	String: string;
	Boolean: boolean;
}

const MyNumber: number = 0;
const MyString: string = "foo";
const MyBool: boolean = false;

export function GetConfiguration<K extends keyof ConfigMap>(key: K): ConfigMap[K] {
	if (key === "Number") {
		return MyNumber;
	} else if (key === "String") {
		return MyString;
	} else if (key === "Boolean") {
		return MyBool;
	}
}

const a = GetConfiguration("Boolean");
const b = GetConfiguration("Number");
const c = GetConfiguration("String");
fatcerberus

fatcerberus commented on Jun 2, 2019

@fatcerberus

Counterexample:

function GetConfiguration<K extends keyof ConfigMap>(fakeKey: K, realKey: K): ConfigMap[K]
{
	if (fakeKey === "Number") {
		return MyNumber;
	} else if (fakeKey === "String") {
		return MyString;
	} else if (fakeKey === "Boolean") {
		return MyBool;
	}
}

The above code will be accepted by TS 3.4, but not by 3.5.

Basically the problem is this: K is a type. Inside the function, we don't know exactly what type it is other than it's some subset of keyof ConfigMap. key is a value of type K, but not the type itself. In general you can't prove anything about a type by checking the value of some example of the type. It just so happens that in this specific case:

  1. The type is a generic type parameter
  2. There is only one incoming value of that type
  3. The other example of the type, the return value, depends on the value of the input.

In the case where all conditions above are met, the code is sound. The compiler can prove 1 and 2, but without dependent types it can't prove 3, and because this is unsafe more often than not, you get a type error to warn you of that.

This is an example of contravariance at work, so I can see why it throws people off (contravariance is confusing!) - but it's there for a good reason 😄

Validark

Validark commented on Jun 2, 2019

@Validark

@fatcerberus I am not sure whether it is "unsafe more often than not", but I don't dispute that a case like the example you gave should make typescript error. I simply think it would be nice if TypeScript allowed cases where the types are sound, and restricted them when they are unsound.

fatcerberus

fatcerberus commented on Jun 2, 2019

@fatcerberus

That's the point though - the compiler has no way to prove that it's safe (that is, it can't distinguish the safe cases from the unsafe ones). In my checklist above, it's only safe when all 3 of those conditions are met, and the compiler can't prove the third condition without dependent types. So the choice you have is either to let all the unsafe cases through (i.e. any case not meeting the 3 conditions, such as the code above), or restrict it entirely at the cost of a few specific examples.

AnyhowStep

AnyhowStep commented on Jun 2, 2019

@AnyhowStep
Contributor

Proposal: introduce one-of type parameter constraint.

declare function foo<T extends oneOf 1|2|3> (t : T) : void;
foo(1); //OK
foo(2); //OK
foo(3); //OK
foo(1 as 1|2); //Error
AnyhowStep

AnyhowStep commented on Jun 2, 2019

@AnyhowStep
Contributor

You can sort of already force users to always pass in a single string literal that is not a union of other types,

type IsAny<T> = (
    0 extends (1 & T) ?
    true :
    false
);

type IsNever<T> = (
    [T] extends [never] ?
    (
        IsAny<T> extends true ?
        false :
        true
    ) :
    false
);

type IsStringLiteral<T extends string> = (
    IsAny<T> extends true ?
    false :
    IsNever<T> extends true ?
    false :
    string extends T ?
    false :
    true
);

type IsOneStringLiteral<T extends string> = (
    IsStringLiteral<T> extends true ?
    (
        {
            [k in T] : (
                Exclude<T, k> extends never ?
                true :
                false
            )
        }[T]
    ) :
    false
);
//false
type a = IsStringLiteral<any>;
//Err: Type 'unknown' does not satisfy the constraint 'string'.ts(2344)
type b = IsStringLiteral<unknown>;
//false
type c = IsStringLiteral<never>;
//false
type d = IsStringLiteral<string>;
//true
type e = IsStringLiteral<"x">;
//true
type f = IsStringLiteral<"x"|"y">;

//false
type _a = IsOneStringLiteral<any>;
//Err: Type 'unknown' does not satisfy the constraint 'string'.ts(2344)
type _b = IsOneStringLiteral<unknown>;
//false
type _c = IsOneStringLiteral<never>;
//false
type _d = IsOneStringLiteral<string>;
//true
type _e = IsOneStringLiteral<"x">;
//false
type _f = IsOneStringLiteral<"x"|"y">;


interface ConfigMap {
    Number: number;
    String: string;
    Boolean: boolean;
}

const MyNumber: number = 0;
const MyString: string = "foo";
const MyBool: boolean = false;

declare function GetConfiguration<K extends Extract<keyof ConfigMap, string>>(
    key : (
        IsOneStringLiteral<K> extends true ?
        K :
        [K, "is not a single string literal"]
    )
): ConfigMap[K];

const a = GetConfiguration("Boolean"); //boolean
const b = GetConfiguration("Number");  //number
const c = GetConfiguration("String");  //string

/*
    Argument of type '"Number" | "Boolean"' is not assignable to
    parameter of type '["Number" | "Boolean", "is not a single string literal"]'.

    Type '"Number"' is not assignable to
    type '["Number" | "Boolean", "is not a single string literal"]'.ts(2345)
*/
const err = GetConfiguration("Boolean" as "Boolean"|"Number");
/*
    Argument of type '"Number" | "String" | "Boolean"' is not assignable to
    parameter of type '["Number" | "String" | "Boolean", "is not a single string literal"]'.

    Type '"Number"' is not assignable to
    type '["Number" | "String" | "Boolean", "is not a single string literal"]'.ts(2345)
*/
const err2 = GetConfiguration("Boolean" as Extract<keyof ConfigMap, string>);

Playground

17 remaining items

Loading
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

    Working as IntendedThe behavior described is the intended behavior; this is not a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @jcalz@rubenpieters@fatcerberus@jack-williams@AnyhowStep

        Issue actions

          Regression when using indexed type depending on function argument as return type · Issue #31672 · microsoft/TypeScript