Skip to content
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

Use conditional type to narrow return type of function with a union-typed argument #24929

Closed
4 tasks done
aneilbaboo opened this issue Jun 13, 2018 · 29 comments
Closed
4 tasks done
Labels
Duplicate An existing issue was already created

Comments

@aneilbaboo
Copy link

aneilbaboo commented Jun 13, 2018

Search Terms

conditional return type narrowing generics

Suggestion

It seems like it should be possible to constrain the return type of a function using a conditional expression:

For example,

// I want my function to have a different return type depending on input
export function foo<T extends string|number>(
  val: T                                  // input type is a union
): T extends string ? string : number {   // output type is either string or number
  return val;
} 
// expected this to work, but received:
// Type 'T' is not assignable to type 'T extends string ? string : number'.
//  Type 'string | number' is not assignable to type 'T extends string ? string : number'.
//    Type 'string' is not assignable to type 'T extends string ? string : number'.

Use Cases

A common use case is a function which can process single objects or arrays of objects, and returns single objects or an array, respectively.

Examples

// capitalize a string or each element of an array of strings
function capitalize<T extends string | string[]>(
  input: T
): T extends string[] ? string[] : string {
  if (isString(input)) {
    return input[0].toUpperCase() + input.slice(1);
  } else {
	return input.map(elt => capitalize(elt));
  }
}

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript / JavaScript code
  • 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. new expression-level syntax)
@aneilbaboo aneilbaboo changed the title Use conditional type for return type to narrow function with union parameter type Use conditional type to narrow return type of function with a union-typed argument Jun 13, 2018
@krryan
Copy link

krryan commented Jun 13, 2018

More features along these lines are really important to me, too.

@AlCalzone
Copy link
Contributor

Duplicate of #22735 it seems. Nonetheless, I agree strongly with OP

@rozzzly
Copy link

rozzzly commented Jun 13, 2018

Would love to see this supported. Until then, there are two workarounds you can use:

1. use an as any cast/assertion
function foo<T extends string | number>(val: T): T extends string ? string : number {
    // requires the use of 'as any' type assertion ☹
	// easily removed when tsserver learns to understand
    return val as any;
}

foo('').charCodeAt(0);    // ✔ No Error (as expected)
foo('').toExponential(2); // ✔ Error (as expected)
foo(3).toExponential(2);  // ✔ No Error (as expected)
foo(3).charCodeAt(0);     // ✔ Error (as expected)
2. function overloads
function foo(val: string): string;
function foo(val: number): number;
function foo<T extends string | number>(val: T): T {
    return val;
}

foo('').charCodeAt(0);    // ✔ No Error (as expected)
foo('').toExponential(2); // ✔ Error (as expected)
foo(3).toExponential(2);  // ✔ No Error (as expected)
foo(3).charCodeAt(0);     // ✔ Error (as expected)

@aneilbaboo
Copy link
Author

@rozzzly That is quite helpful, thank you.

@krryan
Copy link

krryan commented Jun 13, 2018

Note that function overloads are not particularly better here, for typesafety. There is absolutely no checking that your overloads are accurate: only that they could be accurate, based on the primary type signature. Conditionally typing the primary signature might help with that, haven't tested it that thoroughly.

Also note that, at least in my experience, which overload Typescript will choose can be very surprising and (apparently, from the user's perspective) inconsistent.

Due to these issues, our project has strongly encouraged developers to avoid using overloads at all. They are occasionally the least-bad option, as they may be here, but they are rarely ever a good option. They almost-always are a work-around for some limitation in the type system, but it's not always obvious that's what they are, which means casting may be considered superior (because at least it's obvious and honest about what it's doing).

@RyanCavanaugh
Copy link
Member

Duplicate #22735

The core problem here is that type guard operate on values (not type parameters), and you can't actually make meaningful proofs about type parameters because a) they're not manifest at runtime so anything you do is really quite suspect and b) there might be more than one type guard active against expressions matching a type parameter at a time anyway! For example:

function fn<T extends string | number | boolean>(x1: T, x2: T): T extends string ? "s" : T extends number ? "n" : "b" {
    if (typeof x1 === 'string' && typeof x2 === 'number') {
        // ... is this OK?
        return 's';
    }
}

@krryan
Copy link

krryan commented Aug 7, 2018

That wouldn't be OK, because T has to be string | number there and string | number does not extends string. It also fails extends number, so it should return "b". Which makes no sense, but then the function and its return value were poorly chosen—it should probably include an explicit T extends boolean ? "b" : never on there, and then throw in that case, since apparently the assumption is that T will be exactly one of those types for both parameters.

There are a lot of limitations like this where TS doesn't do anything because it cannot make guarantees in the general case, but there are a lot of interesting and useful special cases where guarantees would be possible. Another one that springs to mind immediately is TS not recognizing when unions are mutually exclusive and leveraging that for making guarantees it could not if the unions were not mutually exclusive—note that this doesn't really have anything to do with the ^ operator proposal, because even if we had it, TS doesn't use that information as it might. I have no idea what kind of effort would be involved in leveraging them, or what the priority on them is or should be, but I hope the TS team does consider them important and at least vaguely hopes to someday tackle those kinds of problems, rather than sweeping them all under the "we can't make guarantees in the general case" rug.

@lukeautry
Copy link

I expected this example to work, but it didn't:

type Options = 'yes' | 'no';
const op = <T extends Options>(value: T): T extends 'yes' ? 'good' : 'bad' => {
  if (value === 'no') {
    return 'bad';
  }

  return 'good';
};

Instead, there are two errors: Type '"bad"' is not assignable to type 'T extends "yes" ? "good" : "bad"'. and Type '"good"' is not assignable to type 'T extends "yes" ? "good" : "bad"'. Not sure what I'm missing here.

Works as expected if cast the strings to any.

@jack-williams
Copy link
Collaborator

@krryan
The conditional type is distributive, so that wouldn't be the behaviour. The type string | number would distribute so you would get 's' for the left, and 'n' for the right, giving 's' | 'n'

const x: 's' | 'n' = fn<string | number>("hello",3); // ok

@lukeautry
One issue is that if you pass a value of type never then the conditional type will evaluate to never, which neither 'bad' or 'good' satisfies. You would need to do some work to convince a type-checker things are ok.

type Options = 'yes' | 'no';
const op = <T extends Options>(value: T): T extends 'yes' ? 'good' : 'bad' => {
    if (value === 'yes') return 'good';
    if (value === 'no') return 'bad';
    return value; // Would need to have value narrow to never, which it does not
};

For that to work the compiler really needs to know that T can only be inhabited by two values. But given that fact, you might aswell just write.

type Options = 'yes' | 'no';
function op(value: 'yes'): 'good';
function op(value: 'no'): 'bad';
function op(value: Options): 'good' | 'bad' {
    if (value === 'yes') return 'good';
    if (value === 'no') return 'bad';
    return value; // never
}

The complications that Ryan mentions mean having a general solution that works beyond very simple cases is non-trivial.

@typescript-bot
Copy link
Collaborator

This issue has been marked as a 'Duplicate' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@aleclarson
Copy link

Should @typescript-bot be closing a duplicate when the older issue is locked? That doesn't seem healthy.

@DanielHabenicht
Copy link

Any updates on this? I think we should reopen this, as the other issue got closed (#22735) and this issue is better described.

This is kind of a common use case if you make use of function returning either number or null and want to type check conditionally to save some code checking for null again. As described in this blogpost.

(I am just writing so that the issues does not go stale)

@szmarczak
Copy link

szmarczak commented Dec 2, 2019

@Duncan3142
Copy link

Support for this would make conditional types far more valuable. Is this even on the roadmap?

@kurosh-z
Copy link

kurosh-z commented Apr 6, 2020

I thought something like this might work but ...

type TestFuncRes = {
  type1: number;
  type2: string;
};

type Test<T extends keyof TestFuncRes> = {
  (arg: T): TestFuncRes[T];
};

const testFunc = (arg: keyof TestFuncRes) => {
  const type1: number = 22;
  const type2: string = 'test string';
  return arg === "type1" ? type1: type2
};

const test: Test<'type1'> = testFunc('type1');

Screenshot 2020-04-06 at 11 38 16

any thoughts?

@The-Code-Monkey
Copy link

I have a similar issue to the comment above where I have a helper function, that checks your on the correct product for a feature as well as checking if you have the correct permissions.

import React, { ReactNode } from "react";
import { Product } from "@//platform";
import { CaslEnum } from "@//enums/casl";

import Features from "./features";

import BoundCan from "./";

interface Props {
  children?: ReactNode;
  feature: string;
  I?: CaslEnum;
  not?: boolean;
  passThrough?: boolean;
}

const Can = ({ children, feature, I = CaslEnum.VIEW, not, passThrough }: Props) => {
  const paths = feature.split(":");
  let featureObject = Features;

  paths.forEach(key => {
    featureObject = featureObject[key];
  });

  if (!featureObject.products.includes(Product)) {
    return null;
  }

  if (children) {
    const boundCanProps = {
      this: feature,
      I,
      not,
      passThrough,
    };

    return <BoundCan {...boundCanProps}>{children}</BoundCan>;
  }

  return !featureObject.permissions.includes(I);
};

export default Can;

So you can see that it can be used as a function or as a wrapper for a component, this is so we can disable things your allowed to see but not edit and also hide things that aren't features of the product you are on.

My problem is that when using it as a component wrapper

  <Can feature="test:feat:help" I={CaslEnum.VIEW}>
    <button disabled={Can({ feature: "test:feat:help", I: CaslEnum.EDIT })}>help</button>
  </Can>

It throws an error at both the component usage and the function usage

Function usage = Type 'boolean | Element' is not assignable to type 'boolean | undefined'
Component usage = JSX element type 'boolean | Element | null' is not a constructor function for JSX elements.   Type 'false' is not assignable to type 'Element | null'.

I want to be able to change the return type definition dependent on whether children exists.

@tuchk4
Copy link

tuchk4 commented May 7, 2020

I have a very similar use case

const propOrValue = <
  T extends Record<string, any>,
  K extends keyof T
>(
  obj: T,
  key?: K,
): K extends undefined ? T : T[K] => {
  if (key) {
    return obj[key];
  }

  return obj; // <- error here
};

the possible workaround is use any

return obj as any;

Update: Here is explanation #22735 (comment)

@ivawzh
Copy link

ivawzh commented Jul 25, 2020

@jack-williams the example you gave is not ideal/sound 👇

type Options = 'yes' | 'no';
function op(value: 'yes'): 'good';
function op(value: 'no'): 'bad';
function op(value: Options): 'good' | 'bad' {
    if (value === 'yes') return 'good';
    if (value === 'no') return 'bad';
    return value; // never
}

Ideally, when I change if (value === 'no') return 'bad' to if (value === 'no') return 'good',
it should fail type check as it violates function op(value: 'no'): 'bad'.
However, it does not only pass type check but also sets a wrong type on the result.

Playground Link

@NickDarvey
Copy link

Could this be revisited?

type MappingOf<T extends 'X' | 'Y'> =
  T extends 'X' ? '1'
  : T extends 'Y' ? '2'
  : never

function mappingOf<T extends 'X' | 'Y'>(t : T): MappingOf<T> {
  switch (t) {
    case 'X': return '1'
    // Type '"1"' is not assignable to type 'MappingOf<T>'.ts(2322)
    case 'Y': return '2'
    // Type '"2"' is not assignable to type 'MappingOf<T>'.ts(2322)
  }
}

I'd like to express a mapping between two values in types (MappingOf) so I can express functions on a specific value (e.g. 'X') and get type-safety in the mapping, but I also need a runtime version of the mapping (mappingOf) and I'd like the compiler to force me to do the correct implementation.

@nagromLobo
Copy link

What's the state of this? Is this being tracked by some other issue? Seems like a pretty common js pattern, but can't find this problem tracked in any open issue?

@petr-motejlek
Copy link

Looks like it's dead. Too bad. The best thing about this is that ... wait for it ... the TypeScript docs actually give a function signature with a generic conditional return type as an example, but they smartly leave out the implementation ... is it because it cannot really be implemented in current TypeScript, or what? Why show that off as a feature, when you don't provide a working implementation? :D

None of the workarounds we have so far are type-safe, except for having multiple functions (with different names), but that's not really DRY.

This is one of the cases where logically, you'd think something would just work, but in reality it does not. I originally found this thread trying to figure out how I could get rid of function overloads, because they seem to deal with this, but it's at the expense of the function implementation not really being type-safe, and I don't like that. (TypeScript doesn't really make sure that the function implementation adheres to ALL its overloads).

Using a generic conditional was my second bet, but sadly ... it also is unusable. Makes one wonder why the syntax is even allowed for function return types :D

@lgrahl
Copy link

lgrahl commented Jun 14, 2021

Does the following example also fall into this category?

type In1 = 'a' | 'b';
type Out1<T extends In1> = T extends 'a'
    ? {type: 'a'; a: true}
    : T extends 'b'
    ? {type: 'b'; b: true}
    : never;

function func1<T extends In1>(type: T): Out1<T> {
    switch (type) {
        case 'a':
            // Type '{ type: T; a: true; }' is not assignable to type 'Out1<T>'
            return {type, a: true};
        case 'b':
            // Type '{ type: T; b: true; }' is not assignable to type 'Out1<T>'
            return {type, b: true};
        default:
            unreachable(type);
    }
}

It is a pattern continuously used throughout our project and I can't see anything wrong with it. Mapping like this seems like a fundamental feature of TypeScript to me, so I was confused that it didn't work. Is there a workaround so I can resolve this easily without going into any territory? Edit: It seems that casting the return value to as Out1<T> is sufficient as a workaround.

Can we reopen this or is that particular problem tracked in another issue?

@RyanCavanaugh
Copy link
Member

The general idea of narrowing on generics is tracked at #33014

@JGJP
Copy link

JGJP commented Nov 8, 2021

Can we reopen this though? I lost like 2 hours on this issue going back and reading the docs because I thought I must be missing something (would a PR adding a note in the docs get merged?).

@whschultz
Copy link

I find function overloads to be a cleaner and easier to read approach to specifying conditional return types.

microsoft/TypeScript-Website#1931 (comment)

@krryan
Copy link

krryan commented Mar 15, 2022

@whschultz Function overloads provide the absolute bare minimum of type safety: they only check that the overloaded types could be assigned to the “real” signature, not that the underlying code actually does what it claims with respect to them. The hope for conditional types was that they would provide a greater guarantee within the body of the function, but because of these issues and the need to cast them, they don’t.

@its-dibo
Copy link

its-dibo commented Nov 11, 2022

  1. use an as any cast/assertion

this causes another problem, TS will not report wrong return values

function foo<T extends string|number>(value: T): T extends string ? string: number {  
  return {error: 'not string or number'} as any;
} 

it allowed returning an object which is a non-expecting

  1. function overloads

also, this one causes a problem when you need to return a different type than T

type OutputType<T extends string | number> = T extends string? MyInterface: AnotherInterface

function getValue<T extends string | number>(val: T): OutputType<T>{
 return typeof val==='string'? {type: 'myInterface'} : {type: 'otherInterface'}
}

@ciodc
Copy link

ciodc commented Oct 10, 2023

any other ways for conditional return type except overloads, here is function implementation signature

interface A {
  bar: string;
}
interface B1 {
  bat: string;
}
interface B2 {
  bat: number;
}

type TWrapper<T> = {
  bala: T;
};

async function foo<T>(p0: A & B1): TWrapper<T>;
async function foo<T>(p0: A & B2): T | null {
  ...
};

the extends constraint for p0 not working

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Duplicate An existing issue was already created
Projects
None yet
Development

No branches or pull requests