Execute Program

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 keyof feature. We want to allow get({name: 'Amir'}, 'name'), but not get({name: 'Amir'}, 'age'). Our get<T> function's second argument, key, should be a keyof 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'Pass Icon
  • 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"'.Pass Icon
  • But there's a problem here. Remember that when an object has multiple properties, keyof gives us the union of those properties' keys.

  • Let's consider what get does to our object type of {name: string, age: number}. For that object type, keyof T is 'name' | 'age'. Our function above returns obj[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 is string | 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'.Pass Icon
  • Look closely at the type error message. It's triggered by the const result: string = get(...) line, and it shows that get is returning a string | 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 want get to know that too: the return type of get(user, 'name') should be string, not string | number.

  • Fortunately, we can solve this problem with generic constraints, which we saw in an earlier lesson: <SomeType extends SomeOtherType>. We keep our original T parameter, but also add a second generic type parameter, Key.

  • We tell TypeScript that Key must be a key of our object type: <T, Key extends keyof T>. Finally, we declare a return type of T[Key]: "this function returns the type of the specific property that the caller requested".

  • This makes our get function work as we expect. When we get name, it returns a string. When we get age, it returns a number.

  • >
    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'Pass Icon
  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    const age: number = get({name: 'Amir', age: 36}, 'age');
    age;
    Result:
    36Pass Icon
  • 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"'.Pass Icon
  • 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'):

    1. get's first type parameter is T, with no constraint. We passed a {name: string, age: number}, so that must be T.
    2. get's second type parameter is Key extends keyof T. We passed 'age', which is indeed a key of {name: string, age: number}, so that's allowed.
    3. 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 just number, so that's the return type.
    4. Putting those together, this generic function call's type is (obj: {name: string, age: number}, key: 'age') => number.
  • When we get the 'name' property, everything works in the same way, except that Key is 'name', so the return value is string.

  • So far we've only used get on 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:
    3000Pass Icon
  • What happens if we remove the generic constraint, but leave everything else the same? A type error!

  • Without the constraint, T and Key could be any types, including types with no relationship at all. For example, T could be {name: string} and Key could be number. 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 extends constraint that relates Key to T. Add a constraint that forces the Key type to be a key of the T type.

    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]Pass Icon
  • Our get function shows a common pattern when working with generics: type parameters often depend on other type parameters. In our case, we have T, and then we have Key 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.