Query Properties with keyof and Lookup Types in TypeScript

Marius Schulz
InstructorMarius Schulz
Share this video with your friends

Social Share Links

Send Tweet
Published 7 years ago
Updated 5 years ago

The keyof operator produces a union type of all known, public property names of a given type. You can use it together with lookup types (aka indexed access types) to statically model dynamic property access in the type system.

For this lesson, I've created both a simple Todo interface and a Todo object with some properties.

Let's pretend we want to write a prop function that takes an object and a key, and returns the corresponding value. The idea is that we can use our function like this. We can say prop of Todo, and we're going to get the ID property. Of course, we can do the same thing for text, and also for completed.

Right now, all of the variables we just created have the type any. That is because the signature of the prop function takes any object, any key and returns any value. How could we type the prop function a little better?

First, we are going to find a good solution for the specific Todo type, and then later, we're going to develop a generic solution. Let's go ahead and require object to be a Todo, and let's require key to be any of the strings ID, or text, or completed, because those are the only valid property keys that we have to find in our Todo interface.

This type information alone is enough for TypeScript to give us better return type information. The ID variable is typed to be a string, or a number, or a Boolean. The same goes for text and completed.

This is because the prop function now returns either a string, or a number, or a Boolean. We only accept these three keys -- ID, or text, or completed -- which means we can only get one of the following values.

If we pass in the string ID, we would get back a number, whereas if we pass in text, we would get back a string, or if we pass in completed, we would get back a Boolean. That is now encoded in the type system.

Also note that this prevents us from accessing properties that don't exist. If I were to do something like this, I would get a type error saying that argument of type you did is not assignable to a parameter of type ID, or text, or completed. We're trying to use a key that doesn't exist, so we get a type error, which is great.

It's a little cumbersome to spell out all the property names, and it's also not that maintainable, because if we add another property later, we will have to fix that union type here.

What we can do instead is use the keyof operator. Keyof some type represents all property names of that type as a union type. Here, keyof Todo stands for ID, or text, or completed. Our prop function is still specific to Todo's. Let's go ahead and make it generic so we can use it with other types as well.

We're going to create two new type parameters called T and K. T is the type of the object, and K is the type of the key. Now, there is one restriction that we have to impose, and that is that K must extend keyof T. This means that K must be any of the keys that the type T defines.

We get even smarter type inference. ID is now typed to be a number, text is now typed to be a string, and completed is now typed to be a Boolean. All of this works because the prop function is now inferred to have a return type that is written as T and then K in square brackets. This is called a lookup type or a indexed access type. We can also explicitly add a type annotation here.

Using a lookup type, we can find out what type the property K has within the type T. Here's an example. We can create a type alias called Todo ID, which is equal to the type of the ID property within the Todo type.

We basically look up the type of the ID property within the Todo type. If we check line two, we can see that the ID property is of type number. Therefore, Todo ID now stands for the type number. We can do this for other properties as well.

For example, let's do it with the text property. Todo text stands for a string. We can even union several property keys together, which makes this thing really interesting. Todo text or completed, stands for either a string or a Boolean, because that is the union of the two property types up here.

Notice that our prop function is no longer specific to our Todo type. We've replaced every occurrence of Todo with T and K, so we can use this function with any type we like.

Eric
Eric
~ 7 years ago

is there any convenient way to use Keyof on sub-properties? Like

interface Attributed {
  attributes: {
    [k: string]: v: any;
  }
}
interface AttributedTodo extends Attributed {
  id: number;
  attributes: {
    title: string;
  }
}

function getAttribute<T extends Attributed, K keyof T.attributes>(item: T, key: K) {
  return item.attributes[key];
}
getAttribute(todo, "title");

(which doesn't compile)

Marius Schulz
Marius Schulzinstructor
~ 6 years ago

Hi Eric,

try this version:

interface Attributed {
    attributes: {
        [k: string]: any;
    }
}

interface AttributedTodo extends Attributed {
    id: number;
    attributes: {
        title: string;
        completed: boolean;
    }
}

function getAttribute<T extends Attributed, K extends keyof T["attributes"]>(item: T, key: K): T["attributes"][K] {
    return item.attributes[key];
}

const todo: AttributedTodo = {
    id: 1,
    attributes: {
        title: "Mow the lawn",
        completed: false
    }
};

// Type string
const title = getAttribute(todo, "title");

// Type boolean
const completed = getAttribute(todo, "completed");
Wilgert Velinga
Wilgert Velinga
~ 6 years ago

Please note that it is not necessary to implement and use this prop function in order to get the properties of an object. If you use object destructuring the end result is the same, including the type inference!

interface Todo {
    id: number;
    text: string;
    completed: boolean;
}

const todo: Todo = {
    id: 1,
    text: "Buy milk",
    completed: false
};

const {id, text, completed} = todo;
Filipe Dos Santos Mendes
Filipe Dos Santos Mendes
~ 5 years ago

Hi Marius,

Thank you very much for this course (one which is finally didactic and understandable). I tried to convert your example using a curried function

interface Todo {
  id: number,
  text: string,
  done: boolean
}

const todo: Todo = {
  id: 1,
  text: 'learn TS',
  done: false
}

const prop =
  <T>(obj: T) =>
  <K extends keyof T>(key: K) =>
    obj[key]

const id = prop(todo)('id')

document.body.innerHTML = `Todo ${id}`

It works pretty nice but what if I want to swap the key and the object (like Ramda's prop function)? I tried but K is declared before T and I'm lost there.

Marius Schulz
Marius Schulzinstructor
~ 5 years ago

@Filipe: Glad you liked the course! Take a look at this version and see if it works for you:

interface Todo {
  id: number,
  text: string,
  done: boolean
}

const todo: Todo = {
  id: 1,
  text: 'learn TS',
  done: false
}

const prop =
  <T extends string>(key: T) =>
    <U extends { [P in T]: U[T] }>(value: U) =>
      value[key]

const getID = prop('id')
const id = getID(todo)

document.body.innerHTML = `Todo ${id}`
Dean
Dean
~ 5 years ago

would doing this be "wrong", seems to work. I'm assuming we don't have to because typescript infers this?

function prop<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const id = prop<Todo, 'id'>(todo, "id"); // added the generics here
Marius Schulz
Marius Schulzinstructor
~ 5 years ago

@Dean: No, that's not wrong at all! You're explicitly specifying the type arguments for the prop function call that TypeScript already infers for you. There's no harm in that, but since TypeScript is doing type inference here, I would recommend to leave out the explicit type arguments.

Markdown supported.
Become a member to join the discussionEnroll Today