Advanced TypeScript: Keyof Generic Constraints
Welcome to the Keyof Generic Constraints lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
Suppose that we want to write a function
get(obj, propertyName)that returns the property's value. For example,get({name: 'Amir'}, 'name')should return'Amir'. We want our function to work for any object type, so it needs to be generic.We can write that function by combining TypeScript features from multiple earlier lessons. First, we need generics. Our
get<T>function needs to take any object as its first argument.Second, we need the
keyoffeature. We want to allowget({name: 'Amir'}, 'name'), but notget({name: 'Amir'}, 'age'). Ourget<T>function's second argument,key, should be akeyof T.Here's our first attempt. For now, we're letting TypeScript infer the return type.
>
function get<T>(obj: T, key: keyof T) {return obj[key];}const name = get({name: 'Amir', age: 36}, 'name');name;Result:
'Amir'
It seems to work! And it will correctly give us a type error if we try to request a property that doesn't exist.
>
function get<T>(obj: T, key: keyof T) {return obj[key];}const email = get({name: 'Amir', age: 36}, 'email');email;Result:
type error: Argument of type '"email"' is not assignable to parameter of type '"name" | "age"'.
But there's a problem here. Remember that when an object has multiple properties,
keyofgives us the union of those properties' keys.Let's consider what
getdoes to our object type of{name: string, age: number}. For that object type,keyof Tis'name' | 'age'. Our function above returnsobj[key]. As we saw in an earlier lesson, the object access "distributes over" the union, giving us a union of those properties' types. The final return type of our function call isstring | number.The next example causes a type error for exactly that reason.
>
function get<T>(obj: T, key: keyof T) {return obj[key];}const result: string = get({name: 'Amir', age: 36}, 'name');result;Result:
type error: Type 'string | number' is not assignable to type 'string'. Type 'number' is not assignable to type 'string'.
Look closely at the type error message. It's triggered by the
const result: string = get(...)line, and it shows thatgetis returning astring | number.This is a big problem! If we call
get(user, 'name'), and users' names are strings, then we know we'll get a string back. We wantgetto know that too: the return type ofget(user, 'name')should bestring, notstring | number.Fortunately, we can solve this problem with generic constraints, which we saw in an earlier lesson:
<SomeType extends SomeOtherType>. We keep our originalTparameter, but also add a second generic type parameter,Key.We tell TypeScript that
Keymust be a key of our object type:<T, Key extends keyof T>. Finally, we declare a return type ofT[Key]: "this function returns the type of the specific property that the caller requested".This makes our
getfunction work as we expect. When we getname, it returns astring. When we getage, it returns anumber.>
function get<T, Key extends keyof T>(obj: T, key: Key): T[Key] {return obj[key];}- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const name: string = get({name: 'Amir', age: 36}, 'name');name;Result:
'Amir'
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const age: number = get({name: 'Amir', age: 36}, 'age');age;Result:
36
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const isAdmin = get({name: 'Amir', age: 36}, 'isAdmin');isAdmin;Result:
type error: Argument of type '"isAdmin"' is not assignable to parameter of type '"name" | "age"'.
There's a lot happening in that small function. Let's step through what the compiler sees when we call
get({name: 'Amir', age: 36}, 'age'):get's first type parameter isT, with no constraint. We passed a{name: string, age: number}, so that must beT.get's second type parameter isKey extends keyof T. We passed'age', which is indeed a key of{name: string, age: number}, so that's allowed.- The return type is declared as
T[Key]. If we expand the type parameters out, that's{name: string, age: number}['age']. We can reduce that type to justnumber, so that's the return type. - Putting those together, this generic function call's type is
(obj: {name: string, age: number}, key: 'age') => number.
When we
getthe'name'property, everything works in the same way, except thatKeyis'name', so the return value isstring.So far we've only used
geton our "user" objects. But it works with any type because it's generic.>
function get<T, Key extends keyof T>(obj: T, key: Key): T[Key] {return obj[key];}const port: number = get({hostname: '127.0.0.1', port: 3000}, 'port');port;Result:
3000
What happens if we remove the generic constraint, but leave everything else the same? A type error!
Without the constraint,
TandKeycould be any types, including types with no relationship at all. For example,Tcould be{name: string}andKeycould benumber.T[Key]wouldn't make any sense!Here's a code problem:
The function below is correct, except for one mistake. We've left off the
extendsconstraint that relatesKeytoT. Add a constraint that forces theKeytype to be a key of theTtype.function get<T, Key extends keyof T>(obj: T, key: Key): T[Key] {return obj[key];}const name: string = get({name: 'Amir'}, 'name');name;const amir: {name: string, age: number} = {name: 'Amir', age: 36};[get(amir, 'name'),get(amir, 'age'),];- Goal:
['Amir', 36]
- Yours:
['Amir', 36]
Our
getfunction shows a common pattern when working with generics: type parameters often depend on other type parameters. In our case, we haveT, and then we haveKey extends keyof T. A different function might have<T, A extends Array<T>>, for example.Constraints are critical to advanced use of generics. When a function or type has more than one generic type parameter, there's usually a constraint involved.