1

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"

Sam
  • 375
  • 1
  • 5
  • 15
  • If you can remove the dependency of this question on reactjs I might be able to advise; right now the `useState()` stuff is distracting (to me anyway) if this is just a TS type inference question. For example [this approach](https://tsplay.dev/WPZgJN) might be sufficient for you, but I can't tell right now – jcalz Apr 15 '22 at 21:37
  • it is a type inference question, changed it a bit. – Sam Apr 15 '22 at 21:42
  • So does [this version](https://tsplay.dev/w8oXdW) work for you then? If so, I can write up an answer. If not, what am I missing? – jcalz Apr 15 '22 at 21:44
  • well, it does partially, In the initial example the idea of the function (hook) was to pass an initial value and an options tuple, the tuple must include the type of the initial value whatever it maybe boolean -> [true, false] string -> ['dark', 'light'] object -> [{name: "John"}, {name: "Amy"}] and the returned value should infer the exact value(s) in the tuple – Sam Apr 15 '22 at 21:46
  • Can you add those additional use cases to your example code so that I can test against them? (including the exact required output types for each input) – jcalz Apr 15 '22 at 21:52
  • basically whatever values are passed to the options tuple, as example https://www.typescriptlang.org/play?ts=4.6.2#code/MYewdgzgLgBFIHMEBsCmMC8MA8AVGqAHlKmACYQzQBOAlmAgDQwCqBxpFMuAfABT1aUWgENkANTEBXVAC5WzEAAdh4CPIDauZrgC6ASkw8YAbwCwAKBgxqqKFOpgYGwcLGTkMxStprdAbksAX0tLUEhYDQA3aVRvKAhdTDhEFFQ+EzARAFs5GAAiACtwfKDmDUycvKKSstMs3Pl8nIBPUoN-IA – Sam Apr 15 '22 at 21:55
  • And the exact output type? I mean I can loosen the `string` requirement to a union of things that will cover most types like [this](https://tsplay.dev/wRR0Qw) but I don't know whether `{name: string}` is sufficient or you require `{name: "Jon"}` as the type (and how nested and crazy you need such "exact"-ness to be). – jcalz Apr 15 '22 at 21:58
  • 1
    Note to myself or other interested parties: feels like https://github.com/microsoft/TypeScript/issues/30680 to me – jcalz Apr 15 '22 at 21:59
  • the output type is `{name: "Jon"} | {name: "Amy"}` I am new to typesctipt, and I get the feeling that this does not have a simple "elegant" solution And it does feel like the issue you mentioned – Sam Apr 15 '22 at 22:11
  • 1
    So then maybe [this approach](https://tsplay.dev/WoGYgW)? – jcalz Apr 15 '22 at 22:13
  • yes! that works just fine) – Sam Apr 15 '22 at 22:17
  • Okay I'll write up an answer when I get the chance – jcalz Apr 15 '22 at 22:20

1 Answers1

2

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.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360