Typescript type safe update of property by string property name

1.3k views Asked by At

I'm needing to update the value of a property of a class by a string property name. I started off by making sure the property name was valid via this method:

export class ClientDTO {
    ...

    static isValidPropertyName(name: string): name is keyof ClientDTO {
        return (name as keyof ClientDTO) !== undefined
    }
}

And then in another class I'm doing this:

foo(key: string, newValue: string) {
  if (!ClientDTO.isValidPropertyName(key)) {
    return
  }

  if (newValue !== this.originalClient[key]) {
    // @ts-ignore
    this.originalClient[key] = newValue
  }
}

The lookup works well now, but to do the update I'm having to put the // @ts-ignore there and I'd really like to figure out how to do this properly without having to have the ignore there.

I have strict checkings turned on so I get the error

TS2322: Type 'any' is not assignable to type 'never'

2

There are 2 answers

4
Jeff Bowman On BEST ANSWER
return (name as keyof ClientDTO) !== undefined

This doesn't check that name is a key of ClientDTO. It asserts that it is, and then checks whether the string is undefined. Try it in the playground.

Even if that worked, it would only check that the string is a valid key of ClientDTO, but does not say which one it is. Therefore, Typescript checks that the type you're setting is safely assignable to any key of ClientDTO; since ClientDTO contains "a mix of types" including "String, Boolean, date and number", the only safe value to assign is never.

For you to safely assign a newValue: string, you'll need a function that ensures at runtime that your key is for a string-typed property, which might involve some duplication.

class MyClass {
    constructor(
        public a: string,
        public b: string,
        public c: string,
        public x: number,
        public y: number,
        public z: number) { }
}

function isStringProperty(propertyName: string): propertyName is "a" | "b" | "c" {
    return ["a", "b", "c"].indexOf(propertyName) >= 0;
}

function isNumberProperty(propertyName: string): propertyName is "x" | "y" | "z" {
    return ["x", "y", "z"].indexOf(propertyName) >= 0;
}

function setString(dto: MyClass, key: string, newValue: string) {
    if (isStringProperty(key)) {
        dto[key] = newValue;
    }
}

function setNumber(dto: MyClass, key: string, newValue: number) {
    if (isNumberProperty(key)) {
        dto[key] = newValue;
    }
}

typescript playground

See also: Typescript error:Type 'number' is not assignable to type 'never'

1
CRice On

The problem is that your custom type guard:

isValidPropertyName(name: string): name is keyof ClientDTO { ... }

is guarding against any key of ClientDTO, so when you try to use it:

this.originalClient[key] = newValue // newValue is type string

TypeScript is tries to infer the right type for the value of this.originalClient[key]. Since key could be any key of ClientDTO, the value you assign to it must be assignable to all of the value types of those keys. Since you have a mix of value types for those keys, the only type which is assignable to all of them is the bottom type never; a type to which nothing can be assigned, hence the error.

To fix this, note that you give newValue type string. So restrict your type guard to only those keys of ClientDTO who's values are strings:

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

class ClientDTO {
    /* ... */
    static isValidPropertyName(name: string): name is KeysWithStringValues<ClientDTO> {
        // Make sure to replace this with code that ACTUALLY enforces
        // the above constraint.
        return name !== undefined
    }
}

Playground Link.