-
Notifications
You must be signed in to change notification settings - Fork 12.5k
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
Suggestion for Dependent-Type-Like Functions: Conservative Narrowing of Generic Indexed Access Result Type #33014
Comments
@ahejlsberg this is a co-effort with @jack-williams that looks very promising. Thoughts? |
Silly question but, Will this also narrow What about the more generalized narrowing of Taking @keithlayne 's example from Gitter, interface Foo {
a: number
b: string
c: boolean
}
declare const foo: 'a' | 'b' | 'c'
function f<T extends typeof foo>(x: T, y: Foo[T]) {
if (x === 'a') {
x // T extends "a" | "b" | "c"
}
} Would be nice if Of course, we can't narrow |
Narrowing the type parameter Consider this situation: function f<T extends "t" | "f">(x: T, y: T) {
if (x === 't') {
y // has type "t", but can possibly be inhabited by "f"
}
} |
According to what @jack-williams told me in another issue, it's not necessarily sound to narrow to |
My bad. I meant narrow x. Sorry! |
It is similar to lower bounds, in that the bound is applicable only in a very limited scope: the indexed access type of a function return type. Adding a lower bound into the context can be problematic in scenarios similar as described in the Adding the general form of lower bounds seem more complicated without enabling interesting use cases, compared to keeping the bounds limited in checking the return statement. Plus, adding lower bounds in this way breaks parametricity. That seems ok on first sight. But I'm not sure if this should be part of this proposal.
That is something I would like to investigate as well. |
It's probably better not to think of these as 'true' lower-bounds on the type, rather a lower bound on the type's behaviour as a set of keys. When we see Narrowing |
Don’t you, though? I mean, yes, you can manually instantiate the generic using an unrelated type and then cast, but that’s already unsound in so many other ways that I don’t think we need to account for it at that point. If there’s a case I’m missing where full lower-bounding would make a valid instantiation unsound, please enlighten me, I’m all ears! 👂 |
I don't think lower bounds by themselves create unsoundness. The problem arises when creating interaction between lower bounds and the indexed access simplification. For example in the example below, imagine we add the lower bound function depLikeFun<T extends "t" | "f">(str: T, ft: F[T]): F[T] {
if (str === "t") {
const n: number = ft; // ft would be simplified to `number`, but can be `boolean`
return 1;
} else {
return true;
}
}
let x: boolean = false;
depLikeFun<"t" | "f">("t", x); // ft can be of type 'boolean' |
I think for the initial proposal we would only look at invoking narrowing for specific forms of return type, but ideally we would be more liberal and handle cases like: const enum Flags {
On,
Off
}
interface FlagLights {
[Flags.On]: 'green';
[Flags.Off]: 'red';
}
interface FlagText {
[Flags.On]: 'on';
[Flags.Off]: 'off';
}
function getToggleProps<T extends Flags>(flag: T): { colour: FlagLights[T]; text: FlagText[T] } {
switch (flag) {
case Flags.On:
return { colour: 'green', text: 'on' };
case Flags.Off:
return { colour: 'red', text: 'off' };
}
}
|
Indeed. If we do want to handle narrowing of generic indexed access inside a structure, we do have to take care to only handle the positive positions. Otherwise we get the same problem as I mentioned above. function depLikeFun<T extends "t" | "f">(str: T): (ft: F[T]) => F[T] {
if (str === "t") {
return (ft: number) => { // should be rejected
return 1;
};
} else {
return (ft: F[T]) => {
return true;
};
}
}
depLikeFun<"t" | "f">("t")(true); // ft can be `boolean` |
After experimenting more with this, I feel that limiting the implementation to the Therefore, I suggest to focus the implementation inside the Let's focus on this function which gathers the essence of these interactions: function f<T extends "t" | "f">(
t: T,
t2: T, // second key which we do not test
ft: F[T], // a value of type F[T]
f: F, // a record of type F
) {
if (t === "t") {
const n: number = ft; // a) should be rejected, ft can be bool
f[t2] = 1; // b) should be rejected, f[t2] can be bool
return 1; // c) should be accepted
}
throw "";
} This use case shows three scenarios, two of which should be rejected and one which should be accepted. Scenario A showcases that the variance is important when doing the simplification for this proposal, this information is already part of the Any thoughts / comments are welcome. |
Function overloading provides a functional workaround. {
(str: "t"): number;
(str: "f"): boolean;
} And it can be safely defined this way: type K = string | number | symbol;
const keyIs = <k extends K, C, T extends { [i in k]: unknown; }, V extends { [i in k]: C; }>(
args: T | V, k: k, c: C): args is V => args[k] === c;
function depLikeFun(str: "t"): F["t"];
function depLikeFun(str: "f"): F["f"];
function depLikeFun(...args: ["t"] | ["f"]) {
if (keyIs(args, 0, 't' as const)) {
return 1 as number;
} else {
return true as boolean;
}
}
const myNumber = depLikeFun('t'), myBoolean = depLikeFun('f'); |
@miginmrs This is covered in the Workaround section. As far as I can see, your workaround is unsafe as mentioned. The compiler does not prevent incorrect implementations of |
@rubenpieters Thank you for the note, I didn't notice this before. type depLikeFun<arg, T> = typeof depLikeFun extends { (x: arg): T; } ? T : never;
function depLikeFun(str: "t"): F["t"];
function depLikeFun(str: "f"): F["f"];
function depLikeFun(str: "t" | "f") {
if (str === 't') {
const ret: depLikeFun<typeof str, number> = 1;
return ret;
} else {
const ret: depLikeFun<typeof str, boolean> = true as boolean;
return ret;
}
}
const myNumber = depLikeFun('t'), myBoolean = depLikeFun('f'); |
@miginmrs Actually, I think you can simplify your function depLikeFun(str: "t"): F["t"];
function depLikeFun(str: "f"): F["f"];
function depLikeFun(str: "t" | "f") {
if (str === "t") {
const ret: F[typeof str] = 1;
return ret;
} else {
const ret: F[typeof str] = true as boolean;
return ret;
}
} It requires the discipline of annotating the return type with this expression to prevent mistakes, but is the neatest workaround I've seen so far. |
If I get this right, even the examples lack this feature: TypeScript: Documentation - Conditional Types. Thus, the given function function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
throw "unimplemented";
} and this will fail (without ts-expect-error or explicit cast to function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
if (typeof idOrName === "number") {
// @ts-expect-error
return { id: idOrName };
}
// @ts-expect-error
return { name: idOrName };
} See also TypeScript Playground. |
I believe I just ran into a case where this would apply: type DataNameMap = {
string: string;
number: number;
};
type DataTranslationMap = {
string: DataNameMap["number"];
number: DataNameMap["string"];
};
type DataTranslationObject = {
[key in keyof DataNameMap]: {
arg: {
to: key;
data: DataTranslationMap[key];
};
res: DataNameMap[key];
};
};
type DataTranslationUnion = DataTranslationObject[keyof DataTranslationObject];
function dataTranslation<O extends keyof DataTranslationObject>(arg: {
to: O;
data: DataTranslationObject[O]["arg"]["data"];
}): DataTranslationObject[O]["res"] | undefined;
function dataTranslation<U extends DataTranslationUnion>(
arg: U["arg"]
): U["res"] | undefined {
if (arg.to === "number") {
return Number.parseInt(arg.data);
}
if (arg.to === "string") {
return arg.data.toString();
}
return undefined;
} I believe the overloading and maybe more could be removed if this was implemented, because I only had to add it to link the return type to the argument type. Otherwise, just the union worked fine. Coincidentally, even though all the possibilities are covered, the function still needs an ending return statement. Not sure if this is intended, and how to work around it if it is? I would prefer if that |
What's the current status? |
TypeScript flow doesn't check function dataTranslation<O extends keyof DataTranslationObject>(arg: {
to: O;
data: DataTranslationObject[O]["arg"]["data"];
}): DataTranslationObject[O]["res"] | undefined;
function dataTranslation<U extends DataTranslationUnion>(
arg: U["arg"]
): U["res"] {
switch (arg.to){
case "number": return Number.parseInt(arg.data);
case "string": return arg.data.toString();
}
} |
@craigphicks I don't think there is anything different between |
These examples, not using overloads, are better: declare const x: 1|2;
function f2(): 1|2|undefined {
switch (x){
case 1: return x;
case 2: return x;
}
x; // error - unreachable code detected (but type is displayed as "never"
}
function f3(): 1|2|undefined { // error - not all code paths return a value
if (x===1){
return x;
}
if (x===2){
return x;
}
x; // (not unreachable error) type is displayed as "never"
} So they are both exhaustive with respect to the type, because in both cases the type is displayed as However, with respect to reachability, the I didn't make that clear in my previous post so thank you for pointing it out. |
|
Search Terms
generic indexed access type dependent function
generic narrowing
Suggestion
TypeScript does not provide dependent types, but by combining various features such as unions, literal types and indexed access types we can obtain fairly similar behaviour to a dependent function from the caller point of view. Unfortunately, the implementer of such functions is faced with either the possibility of creating unsoundness (pre 3.5) or forced to assert the return type with unsound features (post 3.5). There are some differences with typical dependent functions, which is why we call these dependent-type-like functions.
This suggestion proposes an addition to the typechecking part of TypeScript, which aids the programmer in writing dependent-type-like functions. The main attractive point of this suggestion is that it does not introduce any additional syntax, and is meant to be a conservative extension. Thus it is not a breaking change with regards to the changes introduced in 3.5, but it does enable certain sound scenarios to typecheck.
Use Case
The main use case is dependent-type-like functions, the
depLikeFun
example is a minimalistic scenario showcasing such a situation. In this example, we have thedepLikeFun
function which returnsnumber
when its input is of type"t"
andboolean
when its input is of type"f"
.This pattern occurs in various places, such as the TypeScript dom bindings, in issues such as #31672 and #32698, in comments of related issues such as #13995, or on stackoverflow questions. This extension could serve as a workaround for related issues such as #22609 and #23861.
Problem
The problem lies in the implementation of the function. The pre-3.5 behaviour enabled the creation of unsoundness in these type of functions. TypeScript checked the return value using the constraint of key type
T
, simplifyingF[T]
tonumber | boolean
. However, this is unsound since the caller can provide a more specific type forT
such as"t"
.The post-3.5 behaviour also isn't satisfactory for this use case. It disallows the
depLikeFun
to be implemented, which means the implementer needs to use unsafe type assertions. By #30769, assigning to typeF[T]
is interpreted as a write toF
at keyT
. This means that the result typeF[T]
is checked against the intersection of its possibilities, which isnumber & boolean
and thusnever
.Mistakes are more likely to occur in complex situations, and thus aiding the user with these types of functions seems in line with TypeScript's design goals.
Examples
In a dependently typed language
depLikeFun
would be modeled asfunction depFun(str: "t" | "f"): F[str]
. There are meaningful differences between depending on the actual value of the input versus the type of an input. This distinction makes this issue more tricky to solve than appears on first sight. In this section we showcase the expected behaviour of the addition on certain representative examples.The main idea behind the addition is as follows: in a dependent-type-like function we cannot narrow the type
T
of a variable when its value is checked. For example, ifstr
has typeT extends "t" | "f"
and we check whetherstr === "t"
, then it is unsafe to narrowT
to"t"
in that branch, sinceT
could also be instantiated with the wider type"t" | "f"
. Instead, we add knowledge about the typeT
within the branch which is more conservative, but makes it possible to allow the behaviour of dependent-type-like functions. In more traditional programming language theory, the knowledge added is very similar to adding a lower bound"t" <: T
into the context.Basic Use Case
First we look at the
depLikeFun
example. In theif
-branch, returning1
is allowed since after checkingstr === "t"
the type ofT
can only be"t"
or"t" | "f"
. Thus, the caller will expect either anumber
ornumber | boolean
, and thus it is safe to return the value1
.Note: The claim above that
T
can only be"t"
or"t" | "f"
is not quite true due to branded types. For example,T
could also be the typeBranded
, defined astype Branded = "t" & {_branded?: any}
. In this case the caller expects a value of typeunknown
, so returning1
is also safe.An example which does not occur for dependent functions, but does occur in dependent-type-like functions, is the following. The type
T
can be attributed to multiple inputs, this possibility is what makes it unsafe to fully narrow the type parameter based on a check of the inputstr
. However, the reasoning above still holds since the added knowledge is true regardless of how many inputsT
is linked to.The extension is conservative, and behaviour of other cases should be the same as in 3.5. However, what might change is the error message users see when implementing dependent-type-like functions. In the situation below, instead of comparing
true
/1
tonever
, they get compared against the simplified indexed access type within each branch. This isnumber
in the then branch andboolean
in the else branch.Non-Return Indexed Access
The type
F[T]
can occur elsewhere in the function. This extension retains the normal TypeScript behaviour in those cases, and is only enabled when checking the return type of a function. In the following example we have a value of typeF[T]
as second input to the function. Within the then branch, this type should not be simplified tonumber
, since it can actually be of typeboolean
. Checking the first input value does not give us any information regarding the second input value, and thus we cannot do any more simplification.Transitive Type Parameters
TypeScript allows an indexed access with a type parameter which has a direct transitive line to a correct bound. For example, it allows
function depLikeFun<T extends "t" | "f", B extends T>(str: B): F[B]
, but disallowsfunction depLikeFun<T extends "t", F extends "f", B extends T | F>(str: B): F[B]
.In these situation it seems safe to let information flow through the transitive chain in both directions. For example, when checking an input of type
B
, we can add knowledge aboutT
. And vice versa for checking an input of typeT
and an input of typeB
.Consistency with Conditional Types
TypeScript provides alternative means of creating type-level functions by using conditional types. Instead of creating the record type
F
, we can create the type constructorF
which provides a similar mapping.Which can be used as seen below.
This raises the question whether the addition of typechecking for dependent-type-like functions should be added for type level functions created with conditional types too, for consistency purposes. Two points worth noting is that: users were never able to implement this behaviour using conditional types (even pre-3.5), and, type level functions with conditional types are not restricted to a domain of string keys which makes things more complex. Nevertheless, we feel it is a point worth bringing up for discussion.
Behaviour with
any
The result of instantiating a dependent-type-like function with the
any
type gives a result ofany
. This occurs, for example, when disabling--strictNullChecks
and calling the function withnull
. Any behaviour related to interaction withnull
/any
and dependent-type-like functions is supposed to be unchanged compared to 3.5.Compiler Changes
In this section we roughly outline the suspected changes to the compiler needed to implement this extension.
checkReturnStatement
(checker.ts) function. When the criteria for enabling dependent-type-like function checking are true, we instantiate the type parameter based on a lookup of checks with literals in the control flow graph. These criteria are currently: the index type is a type parameter, this type parameter is declared in the enclosing function, the index type can be used to index the object type and the object type is concrete.bindWorker
function (binder.ts).Workaround
The current suggested workaround seems to be unsafe type assertions or overloaded signatures, both of which are unsafe features. Neither are satisfactory since the typechecker does not help in preventing programmer mistakes when implementing dependent-type-like functions.
Related Suggestions
The following list gathers related suggestions.
oneof
generic constraint: #25879, #27808 or #30284Complementary to
oneof
constraintsThis proposal has some overlapping use cases with proposals which propose to extend TypeScript with a form of
oneof
generic constraint, as mentioned in point 1. This proposal is not competing with these, but rather should be seen as complementary. Theoneof
proposals suggest a new generic constraint which is more restrictive in which instantiations it allows. However, for many of these use cases this is only part of the solution. In addition to adding this syntax, the typechecker must also take this restriction into account to typecheck the original use cases. This proposal kickstarts the implementation of this additional behaviour by focusing on a more constrained use case which does not need this new constraint. If aoneof
generic constraint is added to TypeScript, the behaviour defined in this proposal can be extended to take this additional constraint into account.Checklist
My suggestion meets these guidelines:
NOTE: (Regarding bullet point 1) The goal of this proposal is to provide more complete typechecking than 3.5, and thus we want to avoid breaking any code compared to 3.5.
The text was updated successfully, but these errors were encountered: