Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript : Generic type "extract keys with value of type X" does not behave as expected

I have defined the following generic type which extracts from a type T the string keys whose value is a number :

type StringKeysMatchingNumber<T> = {
  [K in keyof T]-?: K extends string ?
    T[K] extends number ?
      K
      : never
    : never
}[keyof T];

I try to use this type in a generic function as following :

function setNumberField<T>(item: T, field: StringKeysMatchingNumber<T>): void {
  item[field] = 1;
}

But the line item[field] = 1; errors with Type 'number' is not assignable to type 'T[StringKeysMatchingNumber<T>]'.

I have tried a few different things such as narrowing the generic type T in the function to a type which explicitly contains some string keys with value number but this didn't help.

Anyone can see what the problem is ? Here is a TS playground with the code sample and some more details : https://www.typescriptlang.org/play?#code/C4TwDgpgBAKhDOwbmgXigbwLACgpQEMAuKAOwFcBbAIwgCcAaXfagfhIpvqbygGMSiOgEtSAcx74AJhyq06uAL65cAelVQAgvCgQAHpD7AIU2AmABpCCB3oARATtQAPlDtS7uUJDOIrNqHQAZWARcX94AFkCYD4AC1ExADk5egAeOERkSAA+FRxvaBCwsQjo2ITxFK46DJzAzGYoAG0LKFEoAGtrAHsAM1gAXQBadig2-WNSKR0hRKhWJvwYVsHdPSmZslS6BaX8cf38DggAN3p9k-OFHEVm7pB+oYBufL7yUiNhHtIoeAhgNV5AAxYQQAA2UjqAAphMZKCQYAwoH0wZCSMVEmUYvFEkD0jAcgBKEinHrCUzYXhwiCUZqoiFSNboACMr1uQA

like image 304
acharp Avatar asked Dec 17 '25 22:12

acharp


1 Answers

T in setNumberField is a black box. Nobody knows, even you, whether T has key with numeric value or not. There is not appropriate constraint. setNumberField allows you to provide even primitive value as a first argument. It means that inside function body, TS is unaware that item[field] is always a numerical value. However, TS is aware about it during function call. So function has two levels of typeings. One - is the function definition, when TS is unable to gues the T type and second one - during function call, when TS is aware about T type and able to infer it.

The easiest way to do it is to avoid mutation. You can return new object. Consider this example:

type TestType = {
  a: number,
  b?: number,
  c: string,
  d: number
}

type StringKeysMatchingNumber<T> = {
  [K in keyof T]-?: K extends string ?
  T[K] extends number ?
  K
  : never
  : never
}[keyof T];

const setNumberField = <
  Item,
  Field extends StringKeysMatchingNumber<Item>
>(item: Item, field: Field): Item => ({
  ...item,
  [field]: 1
})

declare let foo: TestType

// {
//     a: number;
//     b: string;
// }
const result = setNumberField({ a: 42, b: 'str' }, 'a')

Playground

Please keep in mind, TypeScript does not like mutations. See my article


If you still want mutate your argument, you should overload your function.

type TestType = {
  a: number,
  b?: number,
  c: string,
  d: number
}

type StringKeysMatchingNumber<T> = {
  [K in keyof T]-?: K extends string ?
  T[K] extends number ?
  K
  : never
  : never
}[keyof T];

function setNumberField<Item, Field extends StringKeysMatchingNumber<Item>>(item: Item, field: Field): void;
function setNumberField(item: Record<string, number>, field: string): void {
  item[field] = 2
}

declare let foo: TestType

const result1 = setNumberField({ a: 42, b: 'str' }, 'a') // ok
const result2 = setNumberField({ a: 42, b: 'str' }, 'b') // expected error

Playground

Function overloading is not so strict. As you might have noticed, this function type definition function setNumberField(item: Record<string, number>, field: string) allows you to use only object where all values are numbers. But this is not the case. This is why I have overloaded this function with another one layer. The bottom one is used for function body. The top one, with StringKeysMatchingNumber controls function arguments.


UPDATE

Why adding a constraint such as T extends Record<string, number> is not enough to make TS aware of the type of item[field]

Consider this:

type StringKeysMatchingNumber<T> = {
  [K in keyof T]-?: K extends string ?
  T[K] extends number ? // This line does not mean that T[K] is equal to number
  K
  : never
  : never
}[keyof T];

This line T[K] extends number means that T[K] is a subtype of number. It can be number & {__tag:'Batman'}. Also, please keep in mind, that StringKeysMatchingNumber might return never and number is not assignable to never:

declare let x: never;
x = 1 // error

Be aware, that calling StringKeysMatchingNumber with static argument like {foo: 42} produces expected result "foo":

type Result = StringKeysMatchingNumber<{ foo: 42 }> // foo

But resolving StringKeysMatchingNumber inside a function body is completely different history. Hover your mouse on Result inside function See example:

function setNumberField<
  T extends Record<string, number>,
  Field extends StringKeysMatchingNumber<T>
>(item: T, field: Field) {

  type Result = StringKeysMatchingNumber<T> // resolving T inside a function

  const value = item[field];

  item[field] = 1; // error
  value.toExponential // ok
}

item[field] is resolved to T[Field], it is not a number type. It is a subtype of number type. Yuo are still allowed to call toExponential. It is very important to understand that item[field] is a type which has all properties of number type but also may contain some other properties. number is not primitive from TS point of view.


//"toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"
type NumberKeys = keyof number

See this:


function setNumberField<
  T extends Record<string, number>,
  Field extends StringKeysMatchingNumber<T>
>(item: T, field: Field) {
  let numSupertype = 5;
  let numSubtype = item[field]
  
  numSupertype = numSubtype // ok
  numSubtype = numSupertype // expected error
}

numSubtype is assignable to numSupertype. item[field] is assignlable to any variable with number type whereas number is not assignable to item[field].

Final question Is the way you want to assign a number to item[field] is type safe enough?

type StringKeysMatchingNumber<T> = {
  [K in keyof T]-?: T[K] extends number ? K : never
}[keyof T];


function setNumberField<

  T extends Record<string, number>,
  Field extends StringKeysMatchingNumber<T>
>(item: T, field: Field) {
  item[field] = 2
}

type BrandNumber = number & { __tag: 'Batman' }

declare let brandNumber: BrandNumber
type WeirdDictionary = Record<string, BrandNumber>

const obj: WeirdDictionary = {
  property: brandNumber
}

setNumberField(obj, 'foo')

setNumberField expects a dictionary where each value extends number type. It means that value might be number & { __tag: 'Batman' }. I know, it is weird from developer perspective but not from type perspective. It is just a subtype of number and this technique is used to mock nominal types. What happens if you assign 2 to item[field] without error ? After calling this function, you expect each value to be BrandNumber but it will not be true.

So, TypeScript does good job here :D

You can find more information about function argument inference in my article

like image 52
captain-yossarian Avatar answered Dec 19 '25 14:12

captain-yossarian



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!