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.
1type RegisteredUser = {2id: string;3name: string;4gender: string;5age: string;6address: string;7};89type 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.
1type 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 fromRegisteredUser
buttimeRegistered
is required.RequiredUser
must include allRegisteredUser
props buttimeRegistered
is optional.
1type PartialUser = Partial<RegisteredUser> & { timeRegistered: Date };2type 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.
1type RegisteredUserUpdate = Omit<RegisteredUser, "gender">;type RegisteredUserUpdate = { id: string; name: string; age: string; address: string; }23// or multiple4type RegisteredUserUpdate = Omit<RegisteredUser, "id" | "gender">;
Pick
On the other hand, I can also just pick the props that I need — amazing!
1type 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.
1type RegisteredUserUpdate = Partial<2Pick<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.
1type ImmutableRegisteredUser = Readonly<RegisteredUser>;
But, wait! What about nested objects? You can try creating your own util type:
1type DeepReadonly<T> = {2readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];3};
1type Address = {2street: string;3city: string;4country: string;5};67type Preferences = {8theme: string;9notifications: {10email: boolean;11sms: boolean;12};13};1415type UserProfile = {16id: string;17name: string;18age: number;19address: Address;20preferences: Preferences;21};2223type DeepReadonly<T> = {24readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];25};2627type DeepImmutableUserProfile = DeepReadonly<UserProfile>;2829const user: DeepImmutableUserProfile = {30id: "12345",31name: "Gio",32age: 1,33address: {34street: "123 Maple St",35city: "Springfield",36country: "USA",37},38preferences: {39theme: "dark",40notifications: {41email: true,42sms: false,43},44},45};4647user.name = "Bob"; // Error: Cannot assign to 'name' because it is a read-only property48user.address.city = "some city.."; // Error: Cannot assign to 'city' because it is a read-only property49user.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 thereadonly
constraint on each property ofT
. - The
P in keyof T
syntax iterates over each key in the typeT
, ensuring every property is mutable.
1type Mutable<T> = {2-readonly [P in keyof T]: T[P];3};
1type RegisteredUser = {2readonly id: string;3readonly name: string;4readonly gender: string;5readonly age: string;6readonly address: string;7};89type Mutable<T> = {10-readonly [P in keyof T]: T[P];11};1213// Creating a mutable version14type MutableRegisteredUser = Mutable<RegisteredUser>;1516const user: MutableRegisteredUser = {17id: "12345",18name: "Alice",19gender: "Female",20age: "30",21address: "123 Main St",22};2324// Modifying the properties25user.name = "Gio"; // No error – it's now mutable26user.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.
1type Role = "admin" | "editor" | "viewer";23type Permissions = {4read: boolean;5write: boolean;6delete: boolean;7};89type RolePermissions = Record<Role, Permissions>;1011const permissions: RolePermissions = {12admin: { read: true, write: true, delete: true },13editor: { read: true, write: true, delete: false },14viewer: { 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:
1type User = {2id: string;3name: string;4age: number;5};67type UserRecord = Record<string, User>;89const users: User[] = [10{ id: "1", name: "Alice", age: 30 },11{ id: "2", name: "Bob", age: 25 },12{ id: "3", name: "Charlie", age: 35 },13];1415const userMap: UserRecord = users.reduce((acc, user) => {16acc[user.id] = user;17return acc;18}, {} as UserRecord);1920console.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:
1type UserData = {2name: string;3email: string;4};56type BasicUserRecord = Record<string, string>;7type DetailedUserRecord = Record<string, UserData>;89type UserRecord<IsDetailed extends boolean> = IsDetailed extends true10? DetailedUserRecord11: BasicUserRecord;1213// Now you can define either a basic or detailed record14const basicUserMap: UserRecord<false> = {15alice: "alice@example.com",16bob: "bob@example.com",17};1819const detailedUserMap: UserRecord<true> = {20alice: { name: "Alice", email: "alice@example.com" },21bob: { name: "Bob", email: "bob@example.com" },22};
It gets even better — this util type can be used for our Factories:
1type ServiceConfig = {2url: string;3apiKey: string;4};56type ServiceMap = "email" | "payment" | "notifications";78type ConfigRecord = Record<ServiceMap, ServiceConfig>;910const createServiceConfig = (11name: ServiceMap,12url: string,13apiKey: string14): ConfigRecord =>15({16[name]: { url, apiKey },17} as ConfigRecord);1819const emailServiceConfig = createServiceConfig(20"email",21"https://email.api.com",22"123-abc-key"23);24console.log(emailServiceConfig);25/*26{27email: { 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 typeLogMessageParams
that represents the tuple[string, number]
. We then use this tuple to ensurelogArgs
has the correct argument types forlogMessage
.
1function logMessage(message: string, code: number): void {2console.log(`${message} - Code: ${code}`);3}45type LogMessageParams = Parameters<typeof logMessage>;type LogMessageParams = [message: string, code: number]67const logArgs: LogMessageParams = ["Hi", 404];89logMessage(...logArgs); // you must spread to make this wor
Here is another example when trying to create Wrapper functions:
loggedFunction
takes any functionfn
and returns a new function with the same parameter types (Parameters<T>
) and return type (ReturnType<T>
).- By using
Parameters<T>
, we ensureloggedAdd
requires the same parameter types as add.
1function loggedFunction<T extends (...args: any[]) => any>(2fn: T3): (...args: Parameters<T>) => ReturnType<T> {4return (...args: Parameters<T>): ReturnType<T> => {5console.log("Function called with arguments:", args);6return fn(...args);7};8}910function add(x: number, y: number): number {11return x + y;12}1314const loggedAdd = loggedFunction(add);15console.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 offetchData
, ensuring consistency without redefining the parameter types manually.
1import ILogger from "./logging/LogContext";2import LogContext from "./logging/LogContext";34export let logger: ILogger;56const init = (name: string) => {7logger = new LogContext(name);89// ...10};1112init("Initialize Project");1314function fetchData(url: string, options: { cache: boolean }): Promise<string> {15// ...1617return Promise.resolve("data");18}1920type FetchDataCallback = (...args: Parameters<typeof fetchData>) => void;2122const logFetch: FetchDataCallback = (url, options) => {23logger.log(`Fetching from ${url} with options`, options);24};2526logFetch("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 typeUser
that represents the return type ofgetUser
. This allows us to ensure the structure of the user object matches the return type, even ifgetUser
changes in the future.
1function getUser(id: number) {2return { id, name: "Gio", age: 1 };3}45type User = ReturnType<typeof getUser>;type User = { id: number; name: string; age: number }67const user: User = getUser(1);8console.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 forcachedCompute
.
1function withCache<T extends (...args: any[]) => any>(fn: T): (...args: Parameters<T>) => ReturnType<T> {2const cache = new Map<string, ReturnType<T>>();3return (...args: Parameters<T>): ReturnType<T> => {4const key = JSON.stringify(args);5if (cache.has(key)) {6return cache.get(key) as ReturnType<T>;7}8const result = fn(...args);9cache.set(key, result);10return result;11};12}1314function computeExpensiveValue(x: number, y: number): number {15console.log("Computing...");16return x * y;17}1819const cachedCompute = withCache(computeExpensiveValue);20console.log(cachedCompute(2, 3)); // Logs "Computing..." then "6"21console.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