Cinnamon
Cinnamon

Reputation: 1698

Implementing a function that infers value type based on the key

I have an object type where keys map to different types:

type Value = {
  str: string;
  num: number;
};

And I’m trying to implement a universal format function:

const format = <K extends keyof Value>(key: K, value: Value[K]): string => {
  if (key === 'str') {
    // @ts-expect-error -- Property 'split' does not exist on type 'string | number'
    return value.split('').join('');
  }
  if (key === 'num') {
    // @ts-expect-error -- The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type
    return `${value - 1}`;
  }
  return '';
};

But why is TypeScript unable to narrow down value’s type inside an if condition?

I have an alternative working solution, but the function signature is quite inconvenient:

type Value =
  | ['str', string]
  | ['num', number];

const format = (...[key, value]: Value): string => {
  if (key === 'str') {
    return value.split('').join('');
  }
  if (key === 'num') {
    return `${value - 1}`;
  }
  return '';
};

Interactive code on TS Playground.

Upvotes: 1

Views: 264

Answers (1)

T.J. Crowder
T.J. Crowder

Reputation: 1074385

But why is TypeScript unable to narrow down value’s type inside an if condition?

Because it wouldn't be sound from a type perspective, but it's something a lot of us trip over, it's not just you. :-) It's not sound because we can supply an explicit type argument that would make value's type ambiguous. One example (possibly the example?) of that is if we provide an explicit type argument with a union of the keys that allows us to give the wrong key for the value type we're providing:

format<"str" | "num">("str", 42);

With the original definition of format, that call raises no errors, but at that callsite K is "str" | "num" (even though key is "str") and so Value[K] is string | number, not string, and we can supply a number for it as above. So key === "str" doesn't, in that call, let us narrow the type of value to just string.

Unions are often the reason (or at least, often a reason) this sort of thing doesn't work when it seems like it should. :-)

You solve that by doing what you've shown in the question, which lets us control the type of both of them in tandem (and in this case, without a type parameter), but it can be simpler with a utility type like the one provided by Titian Cernicova-Dragomir. Here's a genericized version:

type KVTuple<T> = {
    [P in keyof T]: [key: P, value: T[P]];
}[keyof T];

Now we can write format using your original Value interface by using that as the type argument to KVTuple:

const format = (...[key, value]: KVTuple<Value>): string => {
    if (key === "str") {
        return value.split("").join("");
    }
    if (key === "num") {
        return `${value - 1}`;
    }
    return "";
};

The formerly-problematic call is no longer possible:

format<"str" | "num">("str", 42);
//     ^−−−−− Expected 0 type arguments, but got 1. ts(2558)

...and so TypeScript knows that if key is "str", value is string and otherwise (in this case) it's number. And it's not that hard to write (once you have KVTuple) and type hints for the parameters in the IDE still work nicely:

TypeScript playground showing a call to format being written with the key set to "str" and a type hint showing the type of value is string

Full example (playground link):

type Value = {
    str: string;
    num: number;
};

type KVTuple<T> = {
    [P in keyof T]: [key: P, value: T[P]];
}[keyof T];

type X = KVTuple<Value>;

const format = (...[key, value]: KVTuple<Value>): string => {
    if (key === "str") {
        return value.split("").join("");
    }
    if (key === "num") {
        return `${value - 1}`;
    }
    return "";
};

format("str", "text");              // Works
format("num", 1);                   // Works
format("str", 1);                   // Error as desired
format("num", "text");              // Error as desired
format<"str" | "num">("str", 42);   // Error as desired

Upvotes: 2

Related Questions