Type Manipulation in TypeScript
How we use inherent typescript features to enhance type safety and our developer experience.
Hey there, fellow frontend engineers and aspiring ones! Here at LeanIX, we manage a large-scale SaaS application that involves multiple frontend and backend teams working together seamlessly. As you might guess, type safety is absolutely crucial for ensuring smooth collaboration and minimizing potential errors. Thankfully, TypeScript has come to the rescue, providing frontend engineers with the power to alleviate the type-agnostic chaos that JavaScript often brings to the table.
Let's dive into some advanced type manipulation techniques in TypeScript that have been real game-changers for our teams. In this blog post, I'll provide a practical guide to concepts such as mapped types, conditional types, and the keyof Type operator, along with numerous examples to help you apply them in real-world scenarios. Whether you're already a frontend engineer or looking to become one, these techniques will not only improve your TypeScript skills but also contribute to building more robust and type-safe applications. So, let's get started!
Mapped Types
Mapped types are a powerful feature in TypeScript that allows you to create new types by transforming the properties of existing types. They're incredibly useful for generating variations of existing types, such as making all properties optional or read-only.
Let's consider an example where we want to make all properties of a given type optional:
type Optional<T> = {
[K in keyof T]?: T[K];
};
interface User {
id: number;
name: string;
age: number;
}
type OptionalUser = Optional<User>;
Here, OptionalUser
is a new type with all the properties of the User
interface marked as optional. This can be handy when creating partial updates for a user object.
TypeScript also provides some built-in mapped types, such as Partial
and Readonly
. Partial<T>
makes all properties of a type T
optional (an inherent alternative to what we built ourselves above), while Readonly<T>
makes all properties of a type T
read-only.
type ReadonlyUser = Readonly<User>;
type PartialUser = Partial<User>;
Conditional Types
Conditional types are another advanced type manipulation technique in TypeScript that allows you to create types based on conditions. They're useful for filtering or extracting specific types from a union or intersection.
For example, let's say we have a type representing different types of form elements, as in the following Union Type FormElement
:
type FormElement = "input" | "select" | "textarea" | "button";
Now, we want to create a type that includes only the interactive form elements:
type InteractiveElement = Exclude<FormElement, "button">; // "input" | "select" | "textarea"
Here, we use TypeScript's built-in Exclude
conditional type to filter out the "button" type from the FormElement
union.
Another useful conditional type is Extract
, which extracts types that are common to two unions:
type A = "a" | "b" | "c";
type B = "b" | "c" | "d";
type Common = Extract<A, B>; // "b" | "c"
Keyof
and Lookup Types
The keyof
-keyword in TypeScript allows you to create a type representing all property keys of a given type. Combined with lookup types, it enables you to create type-safe property accessors and other utilities.
Consider the following example:
interface Person {
name: string;
age: number;
}
type PersonKeys = keyof Person; // "name" | "age"
The PersonKeys
type represents all possible property keys of the Person
interface. We can use this type to create a type-safe property accessor function:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person: Person = {
name: "Alice",
age: 30,
};
const age: number = getProperty(person, "age"); // 30
In this example, the getProperty
function accepts an object of type T
and a key of type K
, which must be a valid key of T
. The function returns the value of the specified key, and TypeScript ensures that the type of the value is correct.
Combining Techniques
You can combine mapped types, conditional types, and keyof
-operator to create complex type manipulations for your specific needs. Here's an example that demonstrates how these techniques can work together:
interface ApiResponse<T> {
data: T;
errors?: string[];
}
type UnwrapApiResponse<T extends ApiResponse<any>> = T extends ApiResponse<
infer R
>
? R
: never;
type UserResponse = ApiResponse<User>;
type UnwrappedUser = UnwrapApiResponse<UserResponse>; // User
In this example, we first define an ApiResponse
type that represents a typical API response with a data
field of type T
and an optional errors
field. Then, we create a conditional type called UnwrapApiResponse
that extracts the inner data
type from an ApiResponse
.
The infer
keyword in TypeScript is used in the context of conditional types to introduce a type variable that can be inferred from the checked type. In the UnwrapApiResponse
definition, we use the infer R
syntax to declare a type variable R
, which represents the inner data type of the ApiResponse
.
When we use UnwrapApiResponse
with a specific API response type, such as UserResponse
, TypeScript infers the inner data type User
for the R
type variable. As a result, UnwrappedUser
becomes equivalent to the User
type.
In summary, the infer
keyword allows you to introduce a type variable in conditional types that can be inferred based on the checked type. This powerful feature enables you to create flexible and reusable type utilities, such as UnwrapApiResponse
, which can be used to extract inner data types from various API response types.
A More Realistic Example
While toy examples are great at illustrating new concepts, they often lack the complex interactions that make adjustments difficult in the real world. Let's consider a practical example related to IT architecture, specifically modeling API endpoints and their responses. In this scenario, we'll demonstrate the benefits of using advanced type manipulation techniques and the potential dangers of not using them.
Suppose we have a few API endpoints, each returning a different set of data. First, we'll create types to represent the data returned by these endpoints:
interface UsersData {
users: User[];
}
interface ProjectsData {
projects: Project[];
}
interface TasksData {
tasks: Task[];
}
Now, let's define a generic ApiEndpoint
type that takes a data type as a parameter:
type ApiEndpoint<T> = {
path: keyof T;
method: "GET" | "POST" | "PUT" | "DELETE";
responseType: ApiResponse<T>;
};
In this example, the path property has the type keyof T
, ensuring that the path matches a valid property key of the data type T
. This is where the keyof
technique comes in handy.
Next, we'll create specific endpoint types for each of our API endpoints:
type UsersEndpoint = ApiEndpoint<UsersData>;
type ProjectsEndpoint = ApiEndpoint<ProjectsData>;
type TasksEndpoint = ApiEndpoint<TasksData>;
We can now create a mapped type to transform the response types of all our endpoints to their corresponding data types:
type ResponseTypeToDataType<T> = {
[K in keyof T]: T[K] extends ApiResponse<infer R> ? R : never;
};
Using this mapped type and the conditional type T[K] extends ApiResponse<infer R> ? R : never
, we can create a utility type that maps API endpoints to their corresponding data types:
type AllEndpoints = UsersEndpoint & ProjectsEndpoint & TasksEndpoint;
type AllEndpointDataTypes = ResponseTypeToDataType<AllEndpoints>;
With this utility type in place, we can ensure that our API calls return the correct data type based on the endpoint being called. These guards improve type safety and reduces the likelihood of runtime errors due to incorrect data handling.
If we didn't use these advanced type manipulation techniques, we would need to manually specify the expected data type for each API call. This approach is prone to human error and can result in incorrect data types being used, leading to hard-to-debug runtime errors.
Using advanced type manipulation techniques allows us to create a robust and type-safe codebase by enforcing that the correct data types are used throughout the application. By doing so, we can minimize potential errors, improve maintainability, and ensure that our IT architecture is sound and reliable.
Conclusion
Mastering advanced type manipulation techniques in TypeScript, such as mapped types, conditional types, and keyof
, can significantly improve your skills as a frontend engineer and help you write more robust and type-safe code. I hope this practical guide, complete with numerous examples, has provided you with a solid understanding of these concepts and how to apply them in real-world scenarios.
As you continue working with TypeScript, I encourage you to explore these techniques further and integrate them into your projects. They can truly make a difference in your code quality and maintainability. I hope this blog post offered one or two tricks that you didn't know. Until next time, happy coding!
Published by Marc Luettecke
Visit author page