Assigning to a dynamically addressed property in TypeScript interface

Viewed 615

I'm trying to access an object property dynamically from a variable which is a keyof of the object variable type. Example (same as TypeScript playground link):

interface FidelityCheckRow {
    P1: number;
    P2: string;
    P3: string;
}


const keys: (keyof FidelityCheckRow)[] = ['P2', 'P3'];

function test(a: FidelityCheckRow) {
    keys.forEach(key => {
        a[key] = a[key]?.toString()?.trim()
    })

}

When the base object interface contains properties of different types (not all strings), I get the following error:

Type 'string' is not assignable to type 'never'.(2322)

What is illegal in this assignment? And how can I make TypeScript understand my intention?

P.S. Actually I am going to iterate a subset of string properties, maybe this information can help figure out the proper way of doing the thing.

2 Answers

Yes, first extract the string properties of type and then in forEach typescript detect the properties string.

For example:

interface FidelityCheckRow {
    P1: number;
    P2: string;
    P3: string;
}

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

type STRING_KEYS = ExtractStringPropertyNames<FidelityCheckRow>

const keys: STRING_KEYS[] = ['P2', 'P3'];

function tests(a: FidelityCheckRow) {
    keys.forEach(key => {
        a[key] = a[key].toString().trim();
    });
}

Typescript playground

By using the type signature (keyof FidelityCheckRow)[] you specify that each key can be any of the keys of FidelityCheckRow, so also the one with a number value. Without the cast the type will be string[] which is also not what you want, but if you declare keys as a tuple by using as const the type of key will get narrowed down correctly:

interface FidelityCheckRow {
    P1: number;
    P2: string;
    P3: string;
}

const keys = ['P2', 'P3'] as const

function test(a: FidelityCheckRow) {
    keys.forEach(key => {
        a[key] = a[key]?.toString()?.trim()
    })
}

TypeScript playground

About validating the keys:

Since we use keys as a tuple to preserve the exact literal types, we can't use a type signature to validate those keys, as the resulting type would no longer be specific. Incorrect keys will trigger errors on use though (e.g. at a[key]).

There are workarounds to make sure keys only has valid keys, for example by creating a generic type that constrains its parameter to an array of valid keys:

type AssertKeys<K extends ReadonlyArray<keyof FidelityCheckRow>> = never

You can use it by putting a dummy Assertion type near your keys definition and passing it the type of the keys tuple:

const badKeys = ['P2', 'P3', 'P4'] as const
type Assertion = AssertKeys<typeof badKeys>
// ERROR: ... Type '"P4"' is not assignable to type 'keyof FidelityCheckRow'.

Another option is to use a constructor function to create the keys, which is just an identity function that restricts its argument to valid key tuples:

const mkKeys = (ks: ReadonlyArray<keyof FidelityCheckRow>) => ks

If you use mkKeys to create the key tuple, any invalid keys will be flagged:

const mkKeys = <K extends ReadonlyArray<keyof FidelityCheckRow>>(ks: K) => ks

const goodKeys = mkKeys(['P2', 'P3']) // No error
const badKeys = mkKeys(['P2', 'P3', 'P4'])
// ERROR: Type '"P4"' is not assignable to type 'keyof FidelityCheckRow'.

And while we're at it, we might as well parameterize the constructor with the object type and the desired value type, so you can use it for other interfaces than FidelityCheckRow, and other value types than string:

type FilterKeysByValue<T, V> = keyof {
  [K in keyof T as T[K] extends V ? K : never]: never 
}
const mkKeys = <T, V>(ks: ReadonlyArray<FilterKeysByValue<T, V>>) => ks

The error messages are slightly less descriptive, but with the explicit type parameters around, just a squiggly is probably informative enough to see what's wrong:

const goodKeys = mkKeys<FidelityCheckRow, string>(['P2', 'P3']) // No error

const badKeys1 = mkKeys<FidelityCheckRow, string>(['P1', 'P2', 'P3'])
// ERROR: Type '"P1"' is not assignable to type '"P2" | "P3"'.

const badKeys2 = mkKeys<FidelityCheckRow, number>(['P1', 'P2', 'P3'])
// ERROR: Type '"P2"' is not assignable to type '"P1"'.
// ERROR: Type '"P3"' is not assignable to type '"P1"'.

TypeScript playground

Related