Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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"))
like image 606
ideaboxer Avatar asked Jul 22 '17 19:07

ideaboxer


People also ask

How to use the keyof type operator in typescript?

In TypeScript 2.1, they introduced the keyof type operator. The keyof type operator takes an object type and creates a union type of its keys. For example, let’s say we have a User type created with a name as a string and age as a number. We can create a union type of the keys of the User type using the keyof type operator.

What is typescript used for?

Generally, TypeScript provides the set of utility types operators, keywords, and functions used to develop the applications in user prospectiveness.

How do I get the type of an object using generics?

The keyof operator can be used to apply constraints in a generic function. The following function can retrieve the type of an object property using generics, an indexed access type, and the keyof operator. Don't miss a moment with The Replay, a curated newsletter from LogRocket If you are new to TypeScript, this may look a little complex.

How to extract public property names of a type in typescript?

In TypeScript, there are two types, user-defined and primitive, like number, boolean, and string. The user-defined types are usually a combination of primitive types in an object. It can be an object representing nested or simple key-value pairs. We can use the keyof operator to extract the public property names of a type as a union.


1 Answers

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.

like image 108
jcalz Avatar answered Sep 28 '22 11:09

jcalz