question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Relate control flow to conditional types in return types

See original GitHub issue

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 a URL, and a URL isn’t a T 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” Ts 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 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.

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.

Issue Analytics

  • State:open
  • Created 4 years ago
  • Reactions:61
  • Comments:30 (7 by maintainers)

github_iconTop GitHub Comments

14reactions
saiichihashimotocommented, Jun 26, 2021

image

From the documentation specifically on conditional types, it looks like something like this not only should be possible, but a main use case. The same documentation uses this as a replacement for overloads but, from this issue, it looks like there’s no other way. In the documented use case, how would one implement createLabel without extra casts?

6reactions
jack-williamscommented, Oct 9, 2019

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;   
}
Read more comments on GitHub >

github_iconTop Results From Across the Web

Implementing a generic function with a conditional return type
The underlying issue is that TypeScript's compiler does not narrow the type of a generic type variable via control flow analysis.
Read more >
5 Control flow | Advanced R - Hadley Wickham
There are two primary tools of control flow: choices and loops. Choices, like if statements and switch() calls, allow you to run different...
Read more >
Conditionals and Flow Control - John T. Foster
Conditionals and Flow Control Conditional statements are tests to return whether a given comparison between two variables is True or False. They ......
Read more >
Control flow expressions | F# for fun and profit
But as a consequence, both branches must return the same type! If this is not true, then the expression as a whole cannot...
Read more >
Guide to Control Flow Statements in Java - Soshace
Based on this, we can classify the types of control flow statements as follows: Decision Making Statements; Looping Statements; Branching ...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found