control flow conditional return type cannot assign extends
Developers are eager to use conditional types in functions, but this is unergonomic:
type Person = { name: string; address: string; };
type Website = { name: string; url: URL };
declare function isWebsite(w: any): w is Website;
declare function isPerson(p: any): p is Person;
function getAddress<T extends Person | Website>(obj: T): T extends Person ? string : URL {
if (isWebsite(obj)) {
// Error
return obj.url;
} else if (isPerson(obj)) {
// Another error
return obj.address;
}
throw new Error('oops');
}
The errors here originate in the basic logic:
obj.urlis aURL, and aURLisn't aT extends Person ? string : URL
By some mechanism, this function should not have an error.
The current logic is that all function return expressions must be assignable to the explicit return type annotation (if one exists), otherwise an error occurs.
A tempting idea is to change the logic to "Collect the return type (using control flow to generate conditional types) and compare that to the annotated return type". This would be a bad idea because the function implementation would effectively reappear in the return type:
function isValidPassword<T extends string>(s: T) {
if (s === "very_magic") {
return true;
}
return false;
}
// Generated .d.ts
function isValidPassword<T extends string>(s: T): T extends "very_magic" ? true : false;
For more complex implementation bodies, you could imagine extremely large conditional types being generated. This would be Bad; in most cases functions don't intend to reveal large logic graphs to outside callers or guarantee that that is their implementation.
The basic idea is to modify the contextual typing logic for return expressions:
type SomeConditionalType<T> = T extends string ? 1 : -1;
function fn<T>(arg: T): SomeConditionalType<T> {
if (typeof arg === "string") {
return 1;
} else {
return -1;
}
}
Normally return 1; would evaluate 1's type to the simple literal type 1, which in turn is not assignable to SomeConditionalType<T>. Instead, in the presence of a conditional contextual type, TS should examine the control flow graph to find narrowings of T and see if it can determine which branch of the conditional type should be chosen (naturally this should occur recursively).
In this case, return 1 would produce the expression type T extends string ? 1 : never and return -1 would produce the expression type T extends string ? never : -1; these two types would both be assignable to the declared return type and the function would check successfully.
Control flow analysis currently computes the type of an expression given some node in the graph. This process would be different: The type 1 does not have any clear relation to T. CFA would need to be capable of "looking for" Ts to determine which narrowings are in play that impact the check type of the conditional.
Like other approaches from contextual typing, this would not work with certain indirections:
type SomeConditionalType<T> = T extends string ? 1 : -1;
function fn<T>(arg: T): SomeConditionalType<T> {
let n: -1 | 1;
if (typeof arg === "string") {
n = 1;
} else {
n = -1;
}
// Not able to detect this as a correct return
return n;
}
Open question: Maybe this isn't specific to return expressions? Perhaps this logic should be in play for all contextual typing, not just return statements:
type SomeConditionalType<T> = T extends string ? 1 : -1;
function fn<T>(arg: T): SomeConditionalType<T> {
// Seems to be analyzable the same way...
let n: SomeConditionalType<T>;
if (typeof arg === "string") {
n = 1;
} else {
n = -1;
}
return n;
}
The proposed behavior would have the benefit that TS would be able to detect "flipped branch" scenarios where the developer accidently inverted the conditional (returning a when they should have returned b and vice versa).
That said, if we can't make this work, it's tempting to just change assignability rules specifically for return to allow returns that correspond to either side of the conditional - the status quo of requiring very unsafe casts everywhere is not great. We'd miss the directionality detection but that'd be a step up from having totally unsound casts on all branches.
TODO: Many issues have been filed on this already; link them
// Write-once helper
function conditionalProducingIf<LeftIn, RightIn, LeftOut, RightOut, Arg extends LeftIn | RightIn>(
arg: Arg,
cond: (arg: LeftIn | RightIn) => arg is LeftIn,
produceLeftOut: (arg: LeftIn) => LeftOut,
produceRightOut: (arg: RightIn) => RightOut):
Arg extends LeftIn ? LeftOut : RightOut
{
type OK = Arg extends LeftIn ? LeftOut : RightOut;
if (cond(arg)) {
return produceLeftOut(arg) as OK;
} else {
return produceRightOut(arg as RightIn) as OK;
}
}
// Write-once helper
function isString(arg: any): arg is string {
return typeof arg === "string";
}
// Inferred type
// fn: (arg: T) => T extends string ? 1 : -1
function fn<T>(arg: T) {
return conditionalProducingIf(arg, isString,
() => 1 as const,
() => -1 as const);
}
let k = fn(""); // 1
let j = fn(false); // -1
My suggestion meets these guidelines:
Control flow analysis currently computes the type of an expression given some node in the graph. This process would be different: The type 1 does not have any clear relation to T. CFA would need to be capable of "looking for" Ts to determine which narrowings are in play that impact the check type of the conditional.
FWIW: this is exactly the same problem faced here in #33014.
That said, if we can't make this work, it's tempting to just change assignability rules specifically for return to allow returns that correspond to either side of the conditional - the status quo of requiring very unsafe casts everywhere is not great. We'd miss the directionality detection but that'd be a step up from having totally unsound casts on all branches.
Can users not just use an overload to emulate this today?
type SomeConditionalType<T> = T extends string ? 1 : -1;
function fn<T>(arg: T): SomeConditionalType<T>;
function fn<T>(arg: T): 1 | -1 {
if (typeof arg === "string") {
return 1;
} else {
return -1;
}
}
Is it possible to cleanly handle the following, without rebuilding the CFG on demand in the checker?
function fn<T>(arg: T): SomeConditionalType<T> {
return typeof arg === "string" ? 1 : -1;
}
If generic type parameters could be narrowed via control flow analysis then perhaps this would also address #13995?
I don't think this is really a solution to type parameter narrowing in general. There are ways that this could be unsound because of the previously discussed points where a type guard doesn't provide sufficient information to narrow a _type-variable_. Two examples:
// Example 1.
type HasX = { x: number }
function hasX(value: unknown): value is HasX {
return typeof value === "object" && value !== null && typeof (<any>value).x === "number"
}
function foo<T>(point: T): T extends HasX ? number : boolean {
if (hasX(point)) {
return point.x
}
return false;
}
const point: { x: number | boolean } = { x: 3 };
const b: boolean = foo(point);
// Example 2.
type SomeConditionalType<T> = T extends string ? 1 : -1;
function fn<T>(arg: T): SomeConditionalType<T> {
if (typeof arg === "string") {
return 1;
} else {
return -1;
}
}
const shouldBe1: -1 = fn("a string" as unknown);
const isOne: 1 = fn("a string");
const isOneMaybe: 1 | - 1 = fn("a string" as string | number);
I think the solution is relying on two points: the conditional type is distributive, and the constraint of the check type is 'filterable' with respect to the extends type in each conditional type. That is:
Given a type parameter T extends C, and a conditional type T extends U ? A : B, then for all types V:
V is assignable to C, and U and V are not disjointV that is assignable to U.Cross-linking to #22735, the design limitation addressed by this suggestion
Just an SO question as another reference.
As proposed, I think this goes too far in the direction of unsoundness. Knowing that a value arg of type T is a string doesn't tell you that T extends string, it actually tells you that T is a supertype of string! This proposal would basically have the compiler analyze your code to see if it _might_ be doing something correct, and then remove errors and the need for casts, which yes does achieve backwards compatibility and reduced errors and casts, but at a high cost in being able to trust the compiler's judgment.
I think a good starting point would be an example function that is self-consistent in terms of the relationship between its argument types and return type. If fn<string>("x") returns 1 and fn<unknown>("x") returns -1 at the type level, something is already wrong, because a function can't return 1 and -1 for the same input value.
How about something like:
function getTypeCode<T extends number | string>(
t: T
): T extends number
? 'N'
: T extends string
? 'S'
: 'N' | 'S' {
if (typeof t === 'number') {
return 'N'
} else if (typeof t === 'string') {
return 'S'
}
}
Here each conditional specializes the output for a specialized input, like a type-safe overload. Analysis should proceed starting from the return type. If T extends number, does the function return 'N'? If the compiler can prove that, bingo.
@dgreensp I don't believe the proposal in the OP introduces any novel unsoundness, specifically because the logic is only applied to return expressions which are strictly a covariant position. Can you show an example?
I think my examples here show unsound calls.
@RyanCavanaugh In your example:
function fn<T>(arg: T): T extends string ? 1 : -1 {
if (typeof arg === "string") {
return 1;
} else {
return -1;
}
}
Substituting unknown for T:
function fn(arg: unknown): -1 {
if (typeof arg === "string") {
return 1; // error
} else {
return -1;
}
}
Or substituting string | number for T:
function fn(arg: string | number): -1 {
if (typeof arg === "string") {
return 1; // error
} else {
return -1;
}
}
So I think the compiler should not allow the example. It would be great to design this feature so that all valid substitutions for T produce valid typings, first, and only be more lenient if that proves too limiting. Fundamentally, all the compiler can prove at the site of return 1 is that arg is a T and arg is a string. It does not follow that T extends string, so there's no single branch of the conditional that applies to this return statement. (The else branch that returns -1 is a different story, as described below, because if T does extend string, then it is unreachable.)
In my example, the same substitutions are correct, for example substituting unknown for T:
function getTypeCode(
t: unknown
): 'N' | 'S' {
if (typeof t === 'number') {
return 'N'
} else if (typeof t === 'string') {
return 'S'
}
}
A safe alternative proposal would be... I think, first allow returning a value that's assignable to all the branches of the conditional return value (or the intersection of the branches). So in a function whose return value is A extends B ? C : D, you can always return a value of type C & D. Then omit C at those return sites that are unreachable if A is replaced by B.
In other words, the contextual return type is D in places that are unreachable if A is narrowed to B, and C & D otherwise.
So in the original example:
function fn<T>(arg: T): T extends string ? 1 : -1 {
if (typeof arg === "string") {
// contextual type is 1 & -1, so this is an error
return 1;
} else {
// this branch is unreachable in the positive case where T extends string,
// so contextual type is -1, and this is ok
return -1;
}
}
Code that is crafted to be "correct," like my example, will work, as follows:
function getTypeCode<T extends number | string>(
t: T
): T extends number
? 'N'
: T extends string
? 'S'
: 'N' | 'S' {
if (typeof t === 'number') {
// Unreachable when T extends string, so type is 'N' & ('N' | 'S') = 'N'
return 'N'
} else if (typeof t === 'string') {
// Unreachable when T extends number so type is 'S' & ('N' | 'S') = 'S'
return 'S'
}
}
My comment neglects the fact that conditionals distribute over unions. The
string | number example should be replaced by one using a different super
type/subtype relationship that isn’t a union, like superclass/subclass, and
the proposal may need to be modified. The bulk of the argument remains the
same.
On Thu, Jan 9, 2020 at 11:35 AM Jack Williams notifications@github.com
wrote:
I think my examples here
https://github.com/microsoft/TypeScript/issues/33912#issuecomment-540501470
show unsound calls.—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
https://github.com/microsoft/TypeScript/issues/33912?email_source=notifications&email_token=AABGBJSYR4ETSZH23IENQ3LQ4536NA5CNFSM4I7ERYTKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEIRQDJI#issuecomment-572719525,
or unsubscribe
https://github.com/notifications/unsubscribe-auth/AABGBJRKJTQIE7VLLFALVCLQ4536NANCNFSM4I7ERYTA
.
Overloads work for the original example:
function getAddress(obj: Website): URL;
function getAddress(obj: Person): string;
function getAddress(obj: Website | Person) {
if (isWebsite(obj)) {
return obj.url; // no error, error on the overload if the type is changed here
} else if (isPerson(obj)) {
return obj.address; // same
}
throw new Error('oops');
}
What are they doing different?
Here is another use case where it seems to be the same issue:
https://stackblitz.com/edit/rxjs-wdvejf?file=index.ts
Here a conditional type bound to a method argument and return value are unable to handle the valid cast
type AddressBookEntry<T> = T extends AddressBookEntryType.Business
? Business
: T extends AddressBookEntryType.Person
? Person
: never;
const addressBookMap: Map<
AddressBookEntryType,
Map<string, AddressBookEntry<any>>
> = new Map();
const getFromMap = <T extends AddressBookEntryType>(
key: string,
entryType: T
): AddressBookEntry<T> => {
const byTypeMap = addressBookMap.get(entryType);
if (byTypeMap !== null) {
return byTypeMap.get(key); // <-- Error:
// Type 'Business | Person' is not assignable to type 'AddressBookEntry<T>'.
// Type 'Business' is not assignable to type 'AddressBookEntry<T>'.(2322)
} else {
return null;
}
};
Most helpful comment
FWIW: this is exactly the same problem faced here in #33014.
Can users not just use an overload to emulate this today?
Is it possible to cleanly handle the following, without rebuilding the CFG on demand in the checker?