Reputation: 525
how do I make typescript infer the returned value from passed parameter
const toggle = <T, O extends [T,T]>(initialValue: T, options: O) => {
return [initialValue, options];
}
const [value, opts] = toggle("light", ["light", "dark"]);
the type of value is a string
, I need it to be "light" | "dark"
Upvotes: 2
Views: 1542
Reputation: 327994
Conceptually, your toggle()
function could be typed as simply as:
const toggle = <T, U>(initialValue: T | U, options: readonly [T, U]) => {
return [initialValue, options] as const;
}
Here the two generic type parameters T
and U
correspond to the first and second members of the options
tuple (marked as a readonly
tuple which is actually less restrictive than a normal read-write tuple). And the type of initialValue
is the union of T
and U
.
This will catch major errors in your input types:
toggle(3, ["abc", true]); // error!
// --> ~ Argument of type '3' is not assignable to parameter of type 'string | boolean'.
// toggle<string, boolean>(...)
Here T
was inferred as string
, and U
was inferred as boolean
, and the input 3
does not match string | boolean
.
But unfortunately due to the way type inference works in TypeScript, it will not detect the following as an error:
toggle("oops", ["light", "dark"]); // no error
// toggle<string, string>(...)
After all, T
is string
and U
is string
, and "oops"
is also a string
. But you wanted the compiler to treat "light"
, "dark"
, and "oops"
, as string literal types, so that "light"
is of type "light"
, which is not compatible with "oops"
.
The TypeScript compiler uses heuristics to infer the type of a value. When it sees the value {name: "jon"}
, it tends to infer {name: string}
, assuming that "jon"
is just an initializer for a property which may take any string
value. That's often what people want. But sometimes it's not. Sometimes people want the entire value to be treated as immutable as possible, and therefore the type should be as specific as possible.
In these cases you can use a const
assertion to tell the compiler this:
let v = "light"; // string
let w = "light" as const; // "light"
let x = { name: 'jon' }; // { name: string }
let y = { name: 'jon' } as const; // { readonly name: "jon" }
If we use const
assertions on both inputs when calling toggle()
, things will suddenly work how you want:
toggle("oops" as const, ["light", "dark"] as const); // error!
// --> ~~~~~~~~~~~~~~~
// Argument of type '"oops"' is not assignable to parameter of type '"light" | "dark"
const [v1, o1] = toggle("light" as const, ["light", "dark"] as const);
// v1: "light" | "dark"
const [v2, o2] = toggle({ a: 456 } as const, [{ a: 456 }, { b: 789 }] as const)
// v2: { readonly a: 456 } | { readonly b: 789 }
const [v3, o3] = toggle(true as const, [true, false] as const);
// v3: boolean
const [v4, o4] = toggle({ name: "jon" } as const, [{ name: "jon" }, { name: "amy" }] as const);
// v4: { readonly name: "jon" } | { readonly name: "amy" }
So that's great, but it relies on the caller of toggle()
using a const
assertion.
It would be nice if you could implement toggle()
in such a way that the generic inference for the T
and U
type parameters could be "const
-asserted", so that the caller does not have to write as const
if they pass literals into toggle()
.
Unfortunately, there's no simple way to do this. A while back I filed microsoft/TypeScript#30680 requesting support for this, but it's not clear when or if this will be implemented.
For now, there are tricks you can use to get similar behavior, but they are not pretty. If you have a generic type parameter X extends string
, it will tend to infer a string literal type for X
. And X extends number
will do the same for numeric literals. So X extends string | number | boolean
will infer string literals, numeric literals, and boolean literals. But if you want these to be inferred at a nested levels, you need something like X extends string | number | boolean | {[k: string]: X}
. And if you want to infer tuple types instead of unordered arrays, you need to have some tuple types in your domain of inference also, so maybe X extends string | number | boolean | [] | {[k: string]: X}
. And you don't want to prohibit other types, so you need to include other stuff in there like null
and object
. Ideally you'd want to include the unknown
type because it allows everything, but that would throw away all the hinting. So you need to define a Narrowable
type that is like unknown
except it can be used to narrow to literals.
That gives you this:
type Narrowable = string | number | boolean | symbol | bigint
| null | undefined | object | {} | [] | void;
const toggle = <
T extends Narrowable | { [k: string]: T },
U extends Narrowable | { [k: string]: U }
>(initialValue: T | U, options: [T, U]) => {
return [initialValue, options] as const;
}
Let's see if it works:
toggle("oops", ["light", "dark"]); // error
const [v1, o1] = toggle("light", ["light", "dark"]);
// v1: "light" | "dark"
const [v2, o2] = toggle({ a: 456 }, [{ a: 456 }, { b: 789 }])
// v2: { a: 456 } | { b: 789 }
const [v3, o3] = toggle(true, [true, false]);
// v3: boolean
const [v4, o4] = toggle({ name: "jon" }, [{ name: "jon" }, { name: "amy" }]);
// v4: { name: "jon" } | { name: "amy" }
Looks good. It behaves much like the original version when you use as const
, except that it's not inferring readonly
properties for objects... which you didn't really care about to begin with, probably.
Upvotes: 4