Sam
Sam

Reputation: 525

infer exact value typescript

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

Answers (1)

jcalz
jcalz

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.

Playground link to code

Upvotes: 4

Related Questions