Susan Potter
software: Created / Updated

TypeScript's keyof operator and possible uses

A TypeScript example showing how to utilize the keyof operator to return an indexed type from a Configuration type
A TypeScript example showing how to utilize the keyof operator to return an indexed type from a Configuration type

In this article, I'll take you on a journey through the world of TypeScript's keyof operator and its uses.

Type-safe function parameters

keyof is a nifty operator that lets you dive into the heart of an object type, extracting and manipulating its keys with ease. Imagine dynamically accessing and working with specific properties, performing operations, and crafting utility functions tailored the types properties for added type safety. Say goodbye to boilerplate code for each type and embrace the freedom to create a more generic and reusable solution.

type Person = {
  name: string;
  age: number;
  email: string;
};

function getProperty(obj: Person, key: keyof Person) {
  return obj[key];
}

const person: Person = {
  name: "John",
  age: 91,
  email: "john@example.com",
};

// Below we access the `name` and `age` properties
// programmatically from the keys of the type definition
const name = getProperty(person, "name");
const age = getProperty(person, "age");

console.log(name); // Expected output: "John"
console.log(age); // Expected output: 91

The above runs and typechecks just fine but let's try to pass in the name of a property that does not exist on the Person type:

const emailAddress = getProperty(person, "emailAddress");
// Expected type error:
// Argument of type '"emailAddress"' is not assignable
// to parameter of type 'keyof Person'.

Of course, we return to the well with the correct property to retrieve and able to move past the type error:

const email = getProperty(person, "email");
console.log(email) // Expected output: "john@example.com"

Type-based return types

TypeScript's keyof operator can unlock the secret to type property-based return types.

By combining it with indexed access types ([]), you can create functions that strictly accept or return values based on the keys of an object. No more accidental typos or invalid arguments slipping through the cracks! Whether you're dealing with dynamic configurations or APIs that demand specific property names, keyof has got your back.

type Configuration = {
  apiKey: string;
  apiUrl: string;
  timeout: number;
};

const config: Configuration = {
  apiKey: "abc123",
  apiUrl: "https://example.com/api",
  timeout: 5000,
};

function getConfiguration<T extends keyof Configuration>(key: T): Configuration[T] {
  return config[key];
}

const apiKey: string = getConfiguration("apiKey"); // Type-safe parameter usage
console.log(apiKey); // Output: abc123

const timeout: number = getConfiguration("timeout"); // Type-safe return type
console.log(timeout); // Output: 5000

Key/Type Paired arguments

Brace yourself for an exhilarating journey where static analysis takes the stage. With keyof, the compiler becomes your vigilant guardian, meticulously validating your code to catch any mishaps related to property names and other parameters derived from the prpoerty name. Imagine catching those errors early in the development process, paving the way for rock-solid combinator libraries.

Assume we define a Product type with id, name, and price properties with different associated types.

enum Currency {
  USD,
  EUR,
  JPY,
};

type CurrencyAmount = {
  currency: Currency;
  amount: number;
};

type Product = {
  id: number;
  name: string;
  price: CurrencyAmount;
};

function updateProduct(
    product: Product,
    key: keyof Product,
    value: Product[keyof Product]
  )
{
  return { ...product, key: value };
}
const price: CurrencyAmount = {
  currency: Currency.USD,
  amount: 10
};
const product: Product = {
  id: 1,
  name: "Premium",
  price,
};

Dissecting the TypeScript function updateProduct:

  • The function updateProduct is declared with the const keyword. It takes three parameters: product, key, and value.
  • The product parameter is of type Product, which suggests that it expects an object of type Product as an argument.
  • The key parameter is of type keyof Product. Here, keyof Product represents a union type consisting of all the keys (property names) of the Product type. It means that key can only accept a valid key from the Product object.
  • The value parameter is of type Product[keyof Product]. It represents the type of the value corresponding to the key in the Product object. This ensures that the value parameter matches the type of the property indicated by the key parameter.
  • The return type of the updateProduct function is Product, indicating that it will return an object of type Product.
  • Inside the function body, a new object is created using the spread operator (...product) to copy all the properties of the product parameter.
  • The key parameter is used as a property name inside square brackets ([key]) to dynamically access the property in the new object.
  • The value parameter is assigned to the property indicated by the key, effectively updating the corresponding property value.
  • The updated object is returned as the result of the function.

In short, the updateProduct function takes an object of type Product, a valid property name from Product, and a value matching the type of the indicated property. It will create an object with the updated property value and returns it.

Let's try using the updateProduct to change valid properties with values that have types in the property types of Product:

// Below updates the property dynamically restricted to just
// the type of the keys in the type
const returnedProd1 =
  updateProduct(product, "name", "Professional");
console.log(returnedProd1.name); // Output: "Professional"

// Yo, inflation is still a thing so increase the price
const newPrice: CurrencyAmount = {
  currency: Currency.USD,
  amount: 12,
};
const returnedProd2 =
  updateProduct(product, "price", newPrice);
console.log(returnedProd2.price); // Output: {
//  "currency": 0,
//  "amount": 12
//}

Above you'll notice we get the expected behavior where the values of the property update as expected. Yet we all know the happy path is not the most interesting, so what happens when we try to update the product value altering a property that doesn't exist in the Product type?

// Failure mode where key given doesn't match underlying type
updateProduct(product, "quantity", 5);
// Type error: Property 'quantity' does not exist in type
// 'keyof Product'

A type error. Music to my ears.

Now we will try the case where the value parameter given doesn't match the type of any of the underlying type's property types:

// Failure mode where value doesn't match underlying
// property type
updateProduct(product, "id", false);
// Type error: Argument of type 'boolean' is not assignable
// to parameter of type 'string | number | CurrencyAmount'.

Perfect another type error.

A use case I'm Partial<T> to!

However, what if we try to update the id property of the product value to a string?

const returnedProd3 = updateProduct(product, "id", "hello world");
console.log(returnedProd3.id); // Expected output: 1
console.debug(returnedProd3); // Expected output: {
//  "id": 1,
//  "name": "Professional",
//  "price": {
//    "currency": 0,
//    "amount": 12
//  }
//}

This is not ideal since it doesn't result in a type error. However, the behavior retains some weak notion of order by ignoring the invalid update to the id property with a string value instead of its expected type number.

Of course, if you are familiar with the Partial<T> type you will know that it offers a more ergonomic way for application developers to provide a partial object with well-typed key-value pairs corresponding to the T type for full coverage. Let's redefine the updateProduct function as follows:

const updateProduct2 = (
    product: Product,
    updates: Partial<Product>,
  ): Product => ({ ...product, ...updates });

Using the new updateProduct function definition that leverages Partial<Product> offers consumers improved developer ergonomics in multiple ways by:

  • accepting multiple property values to update in one operation
  • improving type safety
const returnedProd4 = updateProduct2(product, {
  name: "Professional",
  price: newPrice,
});
console.debug(returnedProd4); // Expected output: {
//  "id": 1,
//  "name": "Professional",
//  "price": {
//    "currency": 0,
//    "amount": 12
//  }
//}
console.debug(product); // Expected output: {
//  "id": 1,
//  "name": "Premium",
//  "price": {
//    "currency": 0,
//    "amount": 10
//  }
//}

Above you see that we passed in two properties to be updated in the returned object.

Now we will kick the tires on the type-safety of the new definition:

const returnedProd5 = updateProduct2(product, {
  id: "foobar",
});
console.debug(returnedProd5);
// Type error: Type 'string' is not assignable
// to type 'number'.

Now we get a type error. Success!

Under the hook, Partial<T> is still using the keyof indexed type query operator using something like the following definition:

type Partial<T> = {
  [P in keyof T]?: T[P];
};

For people newer to TypeScript I'll break down the TypeScript type definition above:

  • The type Partial<T> is defined. It represents a new type that takes a generic parameter T, which can be any object type.
  • Inside the curly braces {}, we have a key-value pair syntax that defines the structure of the Partial<T> type.
  • [P in keyof T] is a mapped type that iterates over each key P in the keys of the T type. keyof T is a utility type in TypeScript that retrieves all the keys of the type T.
  • The ?: operator indicates that the property is optional. It means that each property of the resulting type Partial<T> may or may not exist in the original type T.
  • T[P] is the type of the property with the key P in the original type T. It ensures that the value assigned to each property in Partial<T> is of the same type as the corresponding property in T. This is the key reason why we get better type-safety with the definition of updateProduct2 function.

By using this type definition, the Partial<T> type allows you to create a new type that shares the same keys as the original type T, but with all properties marked as optional. This is useful when you want to express that some properties of an object can be assigned undefined or omitted altogether.

With Partial<T>, you hold the key to create a brand new type that mirrors the original, but with the freedom to omit or assign undefined to any property. It's like having a personalized toolkit for crafting the perfect object. Say goodbye to rigid structures and embrace the art of possibility.

Update:

  • June 2023: Coming soon I will walk through step by step how to use common types in TypeScript that leverage keyof.

If you enjoyed this content, please consider sharing this link with a friend, following my GitHub, Twitter/X or LinkedIn accounts, or subscribing to my RSS feed.