Skip to content

Relate control flow to conditional types in return types #33912

@RyanCavanaugh

Description

@RyanCavanaugh

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.

Metadata

Metadata

Assignees

Labels

In DiscussionNot yet reached consensusSuggestionAn idea for TypeScript

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions