0

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'

Gargoyle
  • 9,590
  • 16
  • 80
  • 145
  • What error do you get when the `//@ts-ignore` is removed? – CRice May 09 '20 at 00:28
  • TS2322: Type 'any' is not assignable to type 'never' – Gargoyle May 09 '20 at 00:40
  • Are you sure `name as keyof ClientDTO` is validating anything at runtime? Does `ClientDTO` have any non-`string` properties? – Jeff Bowman May 09 '20 at 00:47
  • I am assuming that that `this.originalClient` is an instance of `ClientDTO`, right? If so, are all of the instance properties on it of type `string`? Or is there a mix of types? – CRice May 09 '20 at 00:47
  • Yes it’s a ClientDTO. It’s a mix of types. String, Boolean, date and number. – Gargoyle May 09 '20 at 00:48

2 Answers2

1
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'

Jeff Bowman
  • 90,959
  • 16
  • 217
  • 251
  • Thanks. I didn't know about typescriptlang.org/play, that's so awesome! – Gargoyle May 09 '20 at 01:07
  • BTW: `["x", "y", "z"].includes(propertyName)` is slightly more compact (elegant?) and available and [available since many years](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes#browser_compatibility) – Marcel Waldvogel Jul 31 '22 at 11:42
  • @MarcelWaldvogel And it is completely not supported in Internet Explorer, for which Microsoft only retired support last month. I'm afraid it not practical for me to rephrase the answer in every combination of compatibility and elegance, and I'm sorry my default now and in May 2020 did not match your current opinion. – Jeff Bowman Jul 31 '22 at 17:54
  • This wasn't meant to show your ignorance or to point out the age of yesteryear's code, quite the opposite. SO is used by many novice programmers to build their skills and style or adapt best practices. The comment was meant for them. (As TS is transpiled anyway, I prefer to use new language constructs and APIs whenever useful, and use things like `es6-shim` to polyfill API differences. But these are personal opinions and/or business decisions.) – Marcel Waldvogel Aug 02 '22 at 16:00
1

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.

CRice
  • 29,968
  • 4
  • 57
  • 70