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 typeProduct
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 theProduct
type. It means that key can only accept a valid key from theProduct
object. - The value parameter is of type
Product[keyof Product]
. It represents the type of the value corresponding to the key in theProduct
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 isProduct
, indicating that it will return an object of typeProduct
. - Inside the function body, a new object is created using the spread operator (
...product
) to copy all the properties of theproduct
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 parameterT
, which can be any object type. - Inside the curly braces
{}
, we have a key-value pair syntax that defines the structure of thePartial<T>
type. [P in keyof T]
is a mapped type that iterates over each keyP
in the keys of theT
type.keyof T
is a utility type in TypeScript that retrieves all the keys of the typeT
.- The
?:
operator indicates that the property is optional. It means that each property of the resulting typePartial<T>
may or may not exist in the original typeT
. T[P]
is the type of the property with the keyP
in the original typeT
. It ensures that the value assigned to each property inPartial<T>
is of the same type as the corresponding property inT
. This is the key reason why we get better type-safety with the definition ofupdateProduct2
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.