Blogs
TypeScript Util Types

TypeScript’s Utility Types

Ahhh TS util types…they’re a must when it comes to development.

In this blog I will cover the following:

  • Omit
  • Pick
  • Partial
  • Required
  • Record
  • Parameter
  • ReturnType

Partial

This simplies takes an object and returns partial parts of the object. It does so by implying optional properties.

Typescript
1
type RegisteredUser = {
2
id: string;
3
name: string;
4
gender: string;
5
age: string;
6
address: string;
7
};
8
9
type PartialUser = Partial<RegisteredUser>;
type PartialUser = { id?: string; name?: string; gender?: string; age?: string; address?: string; }

Required

On the other hand, we have Required which just reinforces the props to be present.

Typescript
1
type RequiredUser = Required<RegisteredUser>;
type RequiredUser = { id: string; name: string; gender: string; age: string; address: string; }

Keep in mind, you may also do this to include or exclude props depending on your util type:

  • PartialUser includes optional props from RegisteredUser but timeRegistered is required.
  • RequiredUser must include all RegisteredUser props but timeRegistered is optional.
Typescript
1
type PartialUser = Partial<RegisteredUser> & { timeRegistered: Date };
2
type RequiredUser = Required<RegisteredUser> & { timeRegistered?: Date };

Omit

You’ll often encounter scenarios where you need to use an existing type but exclude one or more properties from it. This is where the Omit utility type shines. Introduced in TypeScript, Omit allows you to create a new type based on an existing one but without certain keys.

  • Prevent Mistakes: By removing unnecessary or sensitive fields (like password, etc), Omit ensures only the relevant data can be modified. This could be an alternative to DTOs.
  • Increase Flexibility: You can create derived types for various use cases (like updates, public-facing types, etc.) without defining them from scratch.
Typescript
1
type RegisteredUserUpdate = Omit<RegisteredUser, "gender">;
type RegisteredUserUpdate = { id: string; name: string; age: string; address: string; }
2
3
// or multiple
4
type RegisteredUserUpdate = Omit<RegisteredUser, "id" | "gender">;

Pick

On the other hand, I can also just pick the props that I need — amazing!

Typescript
1
type RegisteredUserUpdate = Pick<RegisteredUser, "id" | "name">;
type RegisteredUserUpdate = { id: string; name: string; }

From this point, you can start combining util types such as the following:

  • This implies that we pick the values we want and then make them partial, and reinforce subscription to be included.
Typescript
1
type RegisteredUserUpdate = Partial<
2
Pick<RegisteredUser, "id" | "name" | "age">
3
> & { subscription: string };

Readonly

For those who seek for imutability — this is the one! This ensures properties are never modified after they’re initially assigned.

Typescript
1
type ImmutableRegisteredUser = Readonly<RegisteredUser>;

But, wait! What about nested objects? You can try creating your own util type:

Typescript
1
type DeepReadonly<T> = {
2
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
3
};
Typescript
1
type Address = {
2
street: string;
3
city: string;
4
country: string;
5
};
6
7
type Preferences = {
8
theme: string;
9
notifications: {
10
email: boolean;
11
sms: boolean;
12
};
13
};
14
15
type UserProfile = {
16
id: string;
17
name: string;
18
age: number;
19
address: Address;
20
preferences: Preferences;
21
};
22
23
type DeepReadonly<T> = {
24
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
25
};
26
27
type DeepImmutableUserProfile = DeepReadonly<UserProfile>;
28
29
const user: DeepImmutableUserProfile = {
30
id: "12345",
31
name: "Gio",
32
age: 1,
33
address: {
34
street: "123 Maple St",
35
city: "Springfield",
36
country: "USA",
37
},
38
preferences: {
39
theme: "dark",
40
notifications: {
41
email: true,
42
sms: false,
43
},
44
},
45
};
46
47
user.name = "Bob"; // Error: Cannot assign to 'name' because it is a read-only property
48
user.address.city = "some city.."; // Error: Cannot assign to 'city' because it is a read-only property
49
user.preferences.notifications.email = false; // Error: Cannot assign to 'email' because it is a read-only property

But, then, what about mutability?

it’s possible to create a utility type that makes properties mutable, even if they’re marked as readonly in the original type.

  • The -readonly modifier removes the readonly constraint on each property of T.
  • The P in keyof T syntax iterates over each key in the type T, ensuring every property is mutable.
Typescript
1
type Mutable<T> = {
2
-readonly [P in keyof T]: T[P];
3
};
Typescript
1
type RegisteredUser = {
2
readonly id: string;
3
readonly name: string;
4
readonly gender: string;
5
readonly age: string;
6
readonly address: string;
7
};
8
9
type Mutable<T> = {
10
-readonly [P in keyof T]: T[P];
11
};
12
13
// Creating a mutable version
14
type MutableRegisteredUser = Mutable<RegisteredUser>;
15
16
const user: MutableRegisteredUser = {
17
id: "12345",
18
name: "Alice",
19
gender: "Female",
20
age: "30",
21
address: "123 Main St",
22
};
23
24
// Modifying the properties
25
user.name = "Gio"; // No error – it's now mutable
26
user.age = "2"; // No error – it's now mutable

Record

I love records! This is versatile and commonly used to create mapped types, especially when you need a type that maps keys to specific values. It’s helpful for defining structured data with consistent key-value relationships.

Typescript
1
type Role = "admin" | "editor" | "viewer";
2
3
type Permissions = {
4
read: boolean;
5
write: boolean;
6
delete: boolean;
7
};
8
9
type RolePermissions = Record<Role, Permissions>;
10
11
const permissions: RolePermissions = {
12
admin: { read: true, write: true, delete: true },
13
editor: { read: true, write: true, delete: false },
14
viewer: { read: true, write: false, delete: false },
15
};

Here, RolePermissions is a type that maps each role to a Permissions object, ensuring that each role is assigned a set of permissions with consistent keys (read, write, and delete). This structure makes it easy to access and manage role-based permissions in your application.

Here is another great example when it comes to Dynamic Mapping. Suppose we have an array of users and want to transform it into an object where each user ID is a key, and each value is the corresponding user data:

Typescript
1
type User = {
2
id: string;
3
name: string;
4
age: number;
5
};
6
7
type UserRecord = Record<string, User>;
8
9
const users: User[] = [
10
{ id: "1", name: "Alice", age: 30 },
11
{ id: "2", name: "Bob", age: 25 },
12
{ id: "3", name: "Charlie", age: 35 },
13
];
14
15
const userMap: UserRecord = users.reduce((acc, user) => {
16
acc[user.id] = user;
17
return acc;
18
}, {} as UserRecord);
19
20
console.log(userMap);
21
/*
22
{
23
"1": { id: "1", name: "Alice", age: 30 },
24
"2": { id: "2", name: "Bob", age: 25 },
25
"3": { id: "3", name: "Charlie", age: 35 }
26
}
27
*/

There’s more you can do such as Flexible Data Transformations:

Typescript
1
type UserData = {
2
name: string;
3
email: string;
4
};
5
6
type BasicUserRecord = Record<string, string>;
7
type DetailedUserRecord = Record<string, UserData>;
8
9
type UserRecord<IsDetailed extends boolean> = IsDetailed extends true
10
? DetailedUserRecord
11
: BasicUserRecord;
12
13
// Now you can define either a basic or detailed record
14
const basicUserMap: UserRecord<false> = {
15
alice: "alice@example.com",
16
bob: "bob@example.com",
17
};
18
19
const detailedUserMap: UserRecord<true> = {
20
alice: { name: "Alice", email: "alice@example.com" },
21
bob: { name: "Bob", email: "bob@example.com" },
22
};

It gets even better — this util type can be used for our Factories:

Typescript
1
type ServiceConfig = {
2
url: string;
3
apiKey: string;
4
};
5
6
type ServiceMap = "email" | "payment" | "notifications";
7
8
type ConfigRecord = Record<ServiceMap, ServiceConfig>;
9
10
const createServiceConfig = (
11
name: ServiceMap,
12
url: string,
13
apiKey: string
14
): ConfigRecord =>
15
({
16
[name]: { url, apiKey },
17
} as ConfigRecord);
18
19
const emailServiceConfig = createServiceConfig(
20
"email",
21
"https://email.api.com",
22
"123-abc-key"
23
);
24
console.log(emailServiceConfig);
25
/*
26
{
27
email: { url: "https://email.api.com", apiKey: "123-abc-key" }
28
}
29
*/

Good stuff!

Parameters

This utility type allows you to capture the types of a function’s parameters, giving you a tuple of those types. This can be useful in scenarios like type transformations, creating wrappers around functions, or enforcing consistency when working with function parameters in various parts of your application.

Let’s dive into a basic example:

  • Here, Parameters<typeof logMessage> creates a type LogMessageParams that represents the tuple [string, number]. We then use this tuple to ensure logArgs has the correct argument types for logMessage.
Typescript
1
function logMessage(message: string, code: number): void {
2
console.log(`${message} - Code: ${code}`);
3
}
4
5
type LogMessageParams = Parameters<typeof logMessage>;
type LogMessageParams = [message: string, code: number]
6
7
const logArgs: LogMessageParams = ["Hi", 404];
8
9
logMessage(...logArgs); // you must spread to make this wor

Here is another example when trying to create Wrapper functions:

  • loggedFunction takes any function fn and returns a new function with the same parameter types (Parameters<T>) and return type (ReturnType<T>).
  • By using Parameters<T>, we ensure loggedAdd requires the same parameter types as add.
Typescript
1
function loggedFunction<T extends (...args: any[]) => any>(
2
fn: T
3
): (...args: Parameters<T>) => ReturnType<T> {
4
return (...args: Parameters<T>): ReturnType<T> => {
5
console.log("Function called with arguments:", args);
6
return fn(...args);
7
};
8
}
9
10
function add(x: number, y: number): number {
11
return x + y;
12
}
13
14
const loggedAdd = loggedFunction(add);
15
console.log(loggedAdd(5, 3)); // Logs: Function called with arguments: [5, 3]
16
// Returns: 8

Another example could be for Callback Type Consistency:

Suppose we have a function fetchData that takes a URL and options and we want to create a callback that has the same parameter structure.

  • Here, FetchDataCallback is a type that mimics the parameter structure of fetchData, ensuring consistency without redefining the parameter types manually.
Typescript
1
import ILogger from "./logging/LogContext";
2
import LogContext from "./logging/LogContext";
3
4
export let logger: ILogger;
5
6
const init = (name: string) => {
7
logger = new LogContext(name);
8
9
// ...
10
};
11
12
init("Initialize Project");
13
14
function fetchData(url: string, options: { cache: boolean }): Promise<string> {
15
// ...
16
17
return Promise.resolve("data");
18
}
19
20
type FetchDataCallback = (...args: Parameters<typeof fetchData>) => void;
21
22
const logFetch: FetchDataCallback = (url, options) => {
23
logger.log(`Fetching from ${url} with options`, options);
24
};
25
26
logFetch("https://api.example.com", { cache: true });

ReturnType

As seen previously, you’ve already encounter this one. This utility type allows you to extract the return type of a function type.

Basic Example:

  • Here, ReturnType<typeof getUser> creates a type User that represents the return type of getUser. This allows us to ensure the structure of the user object matches the return type, even if getUser changes in the future.
Typescript
1
function getUser(id: number) {
2
return { id, name: "Gio", age: 1 };
3
}
4
5
type User = ReturnType<typeof getUser>;
type User = { id: number; name: string; age: number }
6
7
const user: User = getUser(1);
8
console.log(user); // { id: 1, name: "Alice", age: 30 }
  • One of the most powerful uses of ReturnType is in creating wrapper or higher-order functions that need to return the same type as an inner function. For instance, let’s say you have a caching function that should return the same type as the original function it wraps.

In this example:

  • withCache wraps any function and caches its results.
  • By using ReturnType<T>, withCache returns the exact same type as the wrapped function, ensuring consistent typing for cachedCompute.
Typescript
1
function withCache<T extends (...args: any[]) => any>(fn: T): (...args: Parameters<T>) => ReturnType<T> {
2
const cache = new Map<string, ReturnType<T>>();
3
return (...args: Parameters<T>): ReturnType<T> => {
4
const key = JSON.stringify(args);
5
if (cache.has(key)) {
6
return cache.get(key) as ReturnType<T>;
7
}
8
const result = fn(...args);
9
cache.set(key, result);
10
return result;
11
};
12
}
13
14
function computeExpensiveValue(x: number, y: number): number {
15
console.log("Computing...");
16
return x * y;
17
}
18
19
const cachedCompute = withCache(computeExpensiveValue);
20
console.log(cachedCompute(2, 3)); // Logs "Computing..." then "6"
21
console.log(cachedCompute(2, 3)); // Logs "6" without recomputing

Conclusion

In this blog, we explored some of TypeScript’s most useful utility types, including Omit, Readonly, Record, Parameters, and so on. These utility types offer a powerful way to manipulate and extend types in TypeScript, making your code not only more type-safe but also more flexible and maintainable. Whether you’re building APIs, designing complex data models, or creating reusable functions, TypeScript’s utility types empower you to handle types dynamically without redundancy.

By leveraging these utilities, you can keep your code DRY and ensure that changes in your types propagate safely throughout your codebase. This means fewer bugs, faster refactoring, and a smoother development experience overall. The more you work with these utilities, the more you’ll appreciate how they simplify type management and reduce boilerplate.

I hope this helps!

x, gio