TypeScript: Typing Dynamic Objects and Subsets with Generics

How to use keyof, in, and extends keywords to type dynamic functions in TypeScript

TypeScript gained popularity over 2020 and is now in the upper bracket of the programming salary range. If you are a JavaScript developer you have most likely been exposed to TypeScript in some project you have worked on.

This article, along with others surrounding it, will introduce the reader to advanced TypeScript concepts by explaining them from the ground up with a typical use case — working with objects and subsets of those objects in a dynamic way such that we do not know the exact structure of these objects, but want to define them without compromise.

Being able to define generic types that successfully type objects without compromises is key when working with API responses and event driven applications, whereby the data you receive is filtered and extracted into various components for various functions to handle.

This article focuses on this use case. Instead of working in live environments, we will keep things straightforward by working with simple functions designed to extract properties from objects and return a smaller object of those properties.

The article will gradually build up the complexity of our types with 3 sections:

  • Basic: How to use primitive types with a pick function — a function designed to return a subset of an object’s properties that correspond with the keys provided. The issues with such basic typing will be presented.
  • Intermediate: TypeScript keyword keyof will be explored with generics to enhance our types to coincide with the object properties to be returned by our function.
  • Advanced: We’ll delve deeper by enhancing the pick return type to only include properties being picked out from the object in question. We will be using the extends and in keywords to achieve this.

The examples created in this piece may be achievable via TypeScript’s built-in Utility Types, which will be a subject of another article. The purpose of this piece is to get the reader familiar with common TypeScript features and understanding how to implement them.

Basic typing with primitive types

Let’s assume that we are working with blog posts. Each blog post is provided via article objects that conform to a particular structure. Here is an article with the basic structure we will work with:

// article objectconst article = {
id: 1,
title: "TypeScript Explained",
active: true,
getSlug: () => 'typescript-explained',
};

In order to retrieve properties of this article, let’s introduce a function that allows the developer to “pick” which properties from the article they want. The function simply takes the object in question and an array of keys as its arguments, and will return only those properties corresponding to the keys provided.

// pick function, that returns an object with provided keysfunction pick (obj, keys): object {
throw "not yet implemented"
}

For example, if we executed the following:

pick(article, ["title", "active"]);

We would expect the following object be returned:

{
title: "TypeScript Explained",
active: true
}

Let’s not get caught up with the implementation details here — we are solely focused on correctly typing this function.

Throwing an error inside an unfinished function ensures the TypeScript compiler does not complain about inconsistent return types. It is good practice to structure your functions and define their corresponding types before worrying about implementation details.

The only typing pick has at this stage is a return type of object.

Restricting pick’s arguments with primitive types

Without any real typing defined on pick’s parameters, pick could currently accept any type of value for its two required arguments.

keys should be an array of strings that correspond to one or more of obj’s properties, and obj should always be some object. To start with, let’s limit the key argument to a string array, and obj to just a generic object type:

function pick (obj: object, keys: string[]): object {
throw "not yet implemented"
}

This provides us with some very basic TypeScript protection with our function at the primitive type level. Trying to pass incorrect primitive types into pick will now yield a TypeScript error:

Primitive type checks for the pick function.

There main issue here is that if we tried to extract a non-existent property of an object at this time, the TypeScript compiler would not flag any error:

// `content` property does not exist in `article`, but no error is displayedpick(article, ["title", "content"]);

What is really needed is to limit the key array with only the existing properties of obj. The next section will explore how this is done with common TypeScript features.

Using keys and generics to limit object types

pick needs to ensure that its keys array argument must conform to properties of the obj being provided, but obj could be any object, not just an article — and articles may even have different structures depending on category or blog.

In such a case where obj is an arbitrary type, a generic can be used as its type instead. That generic type can then be used with the keyof keyword to ensure that keys only contain properties of such object.

For a dedicated introduction to TypeScript generics, check out my article on the subject: Typescript Generics Explained.

The following snippet introduces the generic T into pick, and ensures that keys conforms to the properties of obj:

// pick with generic typefunction pick<T> (obj: T, keys: (keyof T)[]): object {
throw "not yet implemented"
}

This version of pick now ensures that keys only accepts properties that exist in obj.

Note that obj is simply of type T — a placeholder type that will conform to the object being passed into pick. T is then used again in the keys type, which states that keys is an array where each value is a key of T.

The terms properties and keys can be used interchangeably when referring to object indexes. When we discuss objects, we often refer to their “properties”, whereas TypeScript adopt the “key” terminology rather dominantly that has lead us to use keys as our pick function’s argument name.

keyof syntax

If we break down this keys typing slightly, the syntax rules are revealed more concisely:

// some article type
type Article = {
title: string,
id: number,
...
}
// some key of article
type Key = keyof Article;
// some array of keys of article
type KeyArray = (keyof Article)[];

As we can see, keyof can be used to represent any valid key of an object. In that latter example, KeyArray wraps keyof Article in parentheses and includes square brackets thereafter to represent that an array is expected.

This is actually what is done with primitive types soo, albeit without the parenthesis:

// string
type S1 = string;
// array of strings
type S1Array = string[];

Defining a generic return type

We still have the generic object type for pick's return type. Here is the function again as a refresher:

// pick with generic `object` return typefunction pick<T> (obj: T, keys: (keyof T)[]): object {
throw "not yet implemented"
}

Let’s now replace object with a custom type that ensures that pick only returns an object consisting of properties of obj. This will still not be a perfect solution for an object subset — as we will discover a little further down.

Let’s first define a Pick<T> type that will define a return type that conforms to obj’s properties:

// generic return type for `pick` that conforms to `obj` propertiestype Pick<T> = { [K in keyof T]: T[K] };function pick<T> (obj: T, keys: (keyof T)[]): Pick<T> {
throw "not yet implemented"
}

So what does Pick<T> actually define? It is essentially saying this type expects an object containing all properties of object T, with corresponding values. A bit more syntax is involved here:

  • The curly braces wrap the entire type definition to denote an object is expected.
  • [K in keyof T] denotes that all properties K in this object must be a key of type T.
  • T[K] denotes the value of T at property K.

We then replace the basic object return type with Pick<T>.

The generic type T is commonly used as the default generic type, with U, V, and so on are used where multiple generics are needed in one type definition.

Typing the return type based on keys provided

This still does not satisfy pick’s purpose: to only return a subset of desired properties that we define via keys. This is evident if we call pick and then check its type:

// extract `title` from article via pick functionconst articleTitle = pick(article, ["title"]);type resolved = typeof articleTitle;

We only expect an object with one title property to be returned here, but if we inspect the resolved type, we can see that the entire article object is expected:

Entire article is expected — even if we only extract `title` from article object

Using the above typeof syntax is a handy way of checking resulting types from functions.

To fix this, we can add another generic U to account for the subset of T. Let’s add this now:

type Pick<T, U extends keyof T> = { [K in U]: T[K] };function pick<T, U extends keyof T> (obj: T, keys: U[]): Pick<T, U> {
throw "not yet implemented"
}

We have introduced two keywords here to define the subset return type:

  • The extends keyword effectively puts a constraint on a type. In this case, U is being constrained by keyof T, whereby U can only consist of keys of T — quite self explanatory. This is done for the Pick type and pick function, effectively linking U with T. Without doing so, U could represent any type, with compile errors very quickly cropping up.
  • Now in the definition of Pick<T, U extends keyof T> we are only expecting some property K in U, which will ultimately be determined by the keys argument of pick. The in keyword can be thought of as a loop (think of an ordinary for-loop) in TypeScript.
  • Note that the type of keys is now U[] to reflect the array of properties we want to pick out.

With these enhancement, the correct return type will now be defined:

Only the `title` property is now being returned by the function, as per the `keys` argument

We have now successfully typed the pick function! It now correctly types the arguments and return type based on the supplied object and keys to extract, with no compromises.

In Summary

This article aimed to give the reader insight into how dynamic types work in TypeScript, and how powerful they can be when working with objects. It is often the case that functions distil objects into smaller subsets, so the features discussed in this article will undoubtedly be useful for your projects.

Continue learning about TypeScript with my piece on conditional types:

Programmer and Author. Director @ JKRBInvestments.com. Creator of LearnChineseGrammar.com for iOS.

Get the Medium app