Description
Search Terms
control flow conditional return type cannot assign extends
Suggestion
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.url
is aURL
, and aURL
isn't aT extends Person ? string : URL
By some mechanism, this function should not have an error.
Dead Ends
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.
Proposal Sketch
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.
Challenges
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" T
s to determine which narrowings are in play that impact the check type of the conditional.
Limitations
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;
}
Fallbacks
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 return
s 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.
Use Cases / Examples
TODO: Many issues have been filed on this already; link them
Workarounds
// 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
Checklist
My suggestion meets these guidelines:
- This wouldn't be a breaking change in existing TypeScript/JavaScript code
- All of these are errors at the moment
- This wouldn't change the runtime behavior of existing JavaScript code
- This could be implemented without emitting different JS based on the types of the expressions
- This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
- This feature would agree with the rest of TypeScript's Design Goals.