TypeScript: How to deal with generic types and the keyof operator

ideaboxer picture ideaboxer · Jul 22, 2017 · Viewed 14.6k times · Source

I try to write a generic function which assembles update data for database updates.

Passed arguments:

  • record to be updated
  • property key
  • a new array item

Even though I restrict the key's type using keyof R, I cannot assign a new object with that key to a Partial<R> constant. I get the error Type '{ [x: string]: any[]; }' is not assignable to type 'Partial<R>'. What can I do to make the following code work? If I replace the generic type R by a non-generic type, it works. But this is not what I need.

Snippet on TypeScript Playground

interface BaseRecord {
    readonly a: ReadonlyArray<string>
}

function getUpdateData<R extends BaseRecord>(record: R, key: keyof R, newItem: string) {
    const updateData: Partial<R> = { [key]: [...record[key], newItem] }
    return updateData
}

interface DerivedRecord extends BaseRecord {
    readonly b: ReadonlyArray<string>
    readonly c: ReadonlyArray<string>
}
const record: DerivedRecord = { a: [], b: [], c: ["first item in c"] }
console.log(getUpdateData<DerivedRecord>(record, "c", "second item in c"))

Answer

jcalz picture jcalz · Jul 23, 2017

You can always bend the type system to your will, either through cunning (e.g., index access and the compiler assuming R[key] is read-write)

function getUpdateData<R extends BaseRecord>(record: R, key: keyof R, newItem: string) {
    var updateData: Partial<R> = {};
    updateData[key] = [...record[key], newItem]; 
    return updateData
}

or brute force (pass through any type):

function getUpdateData<R extends BaseRecord>(record: R, key: keyof R, newItem: string) {
    const updateData: Partial<R> = <any> { [key]: [...record[key], newItem] }
    return updateData
}

The above answers your question, but be careful: this function isn't safe. It assumes any record passed in will have a string[] value for the key property, but the type R might not. For example:

interface EvilRecord extends BaseRecord {
    e: number;
}
var evil: EvilRecord = { a: ['hey', 'you'], e: 42 };
getUpdateData(evil, 'e', 'kaboom');  // compiles okay but runtime error 

Also, the return value type Partial<R> is a little too wide: you know it will have the key key, but you will need to check for it for the type system to be happy:

var updatedData = getUpdateData<DerivedRecord>(record, "c", "first item in c") // Partial<DerivedRecord>
updatedData.c[0] // warning, object is possibly undefined

I'd suggest typing getUpdateData() like this:

type KeyedRecord<K extends string> = {
    readonly [P in K]: ReadonlyArray<string>
};

function getUpdateData<K extends string, R extends KeyedRecord<K>=KeyedRecord<K>>(record: R, key: K, newItem: string) {
    return <KeyedRecord<K>> <any> {[key as string]:  [...record[key], newItem]};
}

(note this is still hard to type correctly because of a bug in TypeScript) Now the function will only accept something where the key property is of type ReadonlyArray<string>, and guarantees that the key property is present in the return value:

var evil: EvilRecord = { a: ['hey', 'you'], e: 42 };
getUpdateData(evil, 'e', 'kaboom'); // error, number is not a string array

var updatedData = getUpdateData(record, "c", "first item in c") // KeyedRecord<"c">
updatedData.c[0] // no error

Hope that helps.


Technical Update

I changed the suggested getUpdateData() declaration above to have two generic parameters, because for some reason TypeScript was inferring a too-wide type for the key parameter before, forcing you to specify the key type at the call site:

declare function oldGetUpdateData<K extends string>(record: KeyedRecord<K>, key: K, newItem: string): KeyedRecord<K>;
oldGetUpdateData(record, "c", "first item in c"); // K inferred as 'a'|'b'|'c', despite the value of 'c'
oldGetUpdateData<'c'>(record, "c", "first item in c"); // okay now 

By adding a second generic, I apparently delayed TypeScript's inference of the record type after it infers the key type correctly:

getUpdateData(record, "c", "hello"); // K inferred as 'c' now

Feel free to ignore this, but this is how sausage is made with heuristic type inference.