conditional return type narrowing generics
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'.
A common use case is a function which can process single objects or arrays of objects, and returns single objects or an array, respectively.
// 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));
}
}
My suggestion meets these guidelines:
More features along these lines are really important to me, too.
Duplicate of #22735 it seems. Nonetheless, I agree strongly with OP
Would love to see this supported. Until then, there are two workarounds you can use:
as any
cast/assertionfunction 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)
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)
@rozzzly That is quite helpful, thank you.
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).
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';
}
}
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.
Here is another example that fails, in the context of code for a project of mine:
http://www.typescriptlang.org/play/#src=type%20Member%20%3D%20string%3B%20%2F%2F%20placeholder%0A%0Afunction%20findMember%3CThrowIfMissing%20extends%20boolean%3E(%0A%20%20%20%20id%3A%20string%2C%20throwIfMissing%3A%20ThrowIfMissing%0A)%3A%20ThrowIfMissing%20extends%20true%20%3F%20Member%20%3A%20(Member%20%7C%20undefined)%0A%7B%0A%20%20%20%20const%20member%20%3D%20'the%20member'%20as%20Member%20%7C%20undefined%3B%0A%20%20%20%20if%20(!throwIfMissing)%20%7B%0A%20%20%20%20%20%20%20%20return%20member%3B%0A%20%20%20%20%7D%0A%20%20%20%20if%20(!member)%20%7B%0A%20%20%20%20%20%20%20%20throw%20new%20Error(%22No%20good!%22)%0A%20%20%20%20%7D%0A%20%20%20%20return%20member%3B%0A%7D
I believe this is the same issue at play. (aside: not sure why the typescript playground is ignoring the undefined type I use in it lol. but the point stands)
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.
@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.
This issue has been marked as a 'Duplicate' and has seen no recent activity. It has been automatically closed for house-keeping purposes.
Should @typescript-bot be closing a duplicate when the older issue is locked? That doesn't seem healthy.
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)
Support for this would make conditional types far more valuable. Is this even on the roadmap?
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');
any thoughts?
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.
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 https://github.com/microsoft/TypeScript/issues/22735#issuecomment-374817151
@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.
Most helpful comment
Would love to see this supported. Until then, there are two workarounds you can use:
1. use an
as any
cast/assertion2. function overloads