TypeScript Mastery: Utility Types in Practice
August 20, 2026
Utility types are one of the places where TypeScript starts to feel less like a type annotation tool and more like a modeling tool.
The main idea is simple:
Utility types let us derive related types from a source model instead of copy-pasting similar interfaces.
That matters because real applications rarely use one model in only one way. A task, user, transaction, or order might need several related shapes:
- a full domain model
- a create payload
- an update payload
- a form draft
- a validated form value
- a smaller view model
- a readonly display model
- a label map
- a mapped return type from a function
Instead of rewriting each shape by hand, TypeScript gives us utility types that transform an existing type into a new related type.
In this lesson, we will use a task manager model and derive several useful application types from it.
Start With the Domain Model
Before using utility types, start with a source model.
type TaskStatus = "todo" | "in-progress" | "done";
type TaskPriority = "low" | "medium" | "high";
type Task = {
readonly id: number;
readonly createdAt: string;
title: string;
description?: string;
status: TaskStatus;
priority: TaskPriority;
assignedTo?: string;
dueDate?: string;
};
This type describes the full task object.
A few details are worth noticing:
readonly id: number;
readonly createdAt: string;
The id and createdAt fields are readonly because they represent system-owned values. The app may display them, but normal editing flows should not casually reassign them.
The literal unions also matter:
type TaskStatus = "todo" | "in-progress" | "done";
type TaskPriority = "low" | "medium" | "high";
These prevent invalid values like:
// const badStatus: TaskStatus = 'blocked';
// const badPriority: TaskPriority = 'urgent';
That gives the rest of the model a controlled vocabulary.
Omit: Create a Type Without Certain Fields
A create payload usually should not include fields like id or createdAt.
Those values are normally generated by the backend or the application itself.
Instead of copying the whole Task type and manually deleting two properties, use Omit.
type CreateTaskInput = Omit<Task, "id" | "createdAt">;
This means:
CreateTaskInput = Task without id and createdAt
Now this is valid:
const createTask: CreateTaskInput = {
title: "Write Angular Tests",
status: "todo",
priority: "high",
};
But this should fail:
// const badCreateTask: CreateTaskInput = {
// id: 1,
// createdAt: '2026-08-20',
// title: 'Bad create',
// status: 'todo',
// priority: 'low',
// };
The create input is based on the task model, but it removes the fields that should not be submitted by the user.
A useful way to read Omit is:
Give me almost the same model, except remove these fields.
Partial: Make Every Property Optional
An update payload usually does not need every field.
If a user only changes the task status, the app might send only:
const updateTask: UpdateTaskInput = {
status: "done",
};
That is where Partial helps.
type UpdateTaskInput = Partial<CreateTaskInput>;
This means:
UpdateTaskInput = any optional subset of CreateTaskInput
So these are all valid:
const updateStatus: UpdateTaskInput = {
status: "done",
};
const updateTitle: UpdateTaskInput = {
title: "Refactor Angular service",
};
const updateSeveralFields: UpdateTaskInput = {
title: "Write unit tests",
priority: "high",
assignedTo: "Chris",
};
A highlight I want to bring up is that Partial is not just a shortcut. It communicates intent.
When another developer sees:
type UpdateTaskInput = Partial<CreateTaskInput>;
They can read it as:
This is an update-like shape where any subset of create fields may be provided.
That is clearer than a hand-written copied interface with lots of optional properties.
Required: Make Every Property Required
Sometimes the application has a draft form state where fields may be missing while the user is still typing.
type TaskFormDraft = Partial<CreateTaskInput>;
const formDraft: TaskFormDraft = {
title: "Draft title",
};
That makes sense for an unfinished form.
But after validation, maybe the app wants a stricter form value where everything exists.
type ValidatedTaskForm = Required<CreateTaskInput>;
A valid object now needs every property from CreateTaskInput.
const validTaskForm: ValidatedTaskForm = {
title: "The Form",
description: "A valid task form.",
status: "in-progress",
priority: "low",
dueDate: "2026-08-23",
assignedTo: "Chris",
};
A highlight I want to bring up is that Required<T> can be stricter than expected.
Because CreateTaskInput includes optional fields like:
description?: string;
assignedTo?: string;
dueDate?: string;
Required<CreateTaskInput> makes those required too.
That may or may not be what the app actually wants. Utility types are powerful, but they still need to match the business meaning of the model.
Pick: Create a Smaller View Model
A UI card usually does not need the full task object.
It might only need:
idtitlestatuspriority
Use Pick when you only want selected fields.
type TaskCardViewModel = Pick<Task, "id" | "title" | "status" | "priority">;
This means:
TaskCardViewModel = only id, title, status, and priority from Task
A valid card might look like this:
const card: TaskCardViewModel = {
id: 1,
title: "Write Angular Tests",
status: "todo",
priority: "high",
};
Use Pick when the model should stay connected to the original type, but the UI only needs a smaller slice.
Readonly: Protect a Display Model From Reassignment
A task card might be shown in the UI but not directly edited.
type ReadonlyTaskCard = Readonly<TaskCardViewModel>;
Now this is valid:
const readonlyCard: ReadonlyTaskCard = {
id: 1,
title: "Write Angular Tests",
status: "todo",
priority: "high",
};
But this should fail:
// readonlyCard.title = 'Angular Tests';
Readonly<T> communicates:
This object should not be reassigned through this reference.
A highlight I want to bring up is that Readonly<T> is TypeScript compile-time protection. It helps during development, but it does not freeze the object at runtime by itself.
Record: Map Every Key in a Union to a Value
Record is useful when every value in a union needs a matching label, class, icon, or configuration.
For example:
type TaskStatusLabels = Record<TaskStatus, string>;
This means:
Every TaskStatus key must exist.
Every key must map to a string.
Since TaskStatus is:
type TaskStatus = "todo" | "in-progress" | "done";
The label object must include all three keys.
const statusLabels: TaskStatusLabels = {
todo: "To Do",
"in-progress": "In Progress",
done: "Done",
};
This object is valid because it includes:
todo
in-progress
done
The values are strings:
To Do
In Progress
Done
One common point of confusion is why 'in-progress' needs quotes while todo and done do not.
This is JavaScript object syntax.
These are valid bare property names:
const labels = {
todo: "To Do",
done: "Done",
};
But this is not valid:
// const labels = {
// in-progress: 'In Progress',
// };
JavaScript reads that as subtraction:
in - progress
So a key with a hyphen must be quoted:
const labels = {
"in-progress": "In Progress",
};
The values also need quotes because they are strings meant to be shown to users.
Priority labels follow the same pattern:
type TaskPriorityLabels = Record<TaskPriority, string>;
const priorityLabels: TaskPriorityLabels = {
low: "Low",
medium: "Medium",
high: "High",
};
This is useful because TypeScript will complain if one key is missing.
For example, this should fail:
// const badPriorityLabels: TaskPriorityLabels = {
// low: 'Low',
// medium: 'Medium',
// };
The high key is missing.
A useful way to read Record is:
For every key in this union, give me a value of this type.
ReturnType: Extract the Type a Function Returns
Sometimes a view model is created by a mapper function.
function mapTaskToCard(task: Task) {
return {
id: task.id,
title: task.title,
status: task.status,
priority: task.priority,
isOverdue: Boolean(task.dueDate && task.status !== "done"),
};
}
Instead of manually creating a separate type that might drift away from the mapper, use ReturnType.
type TaskCard = ReturnType<typeof mapTaskToCard>;
This means:
TaskCard = whatever mapTaskToCard returns
Now the type stays connected to the function.
const mappedCard: TaskCard = mapTaskToCard({
id: 33,
createdAt: "2026-08-22",
title: "My Task",
description: "The task I must do.",
priority: "medium",
status: "todo",
assignedTo: "Chris",
});
A highlight I want to bring up is that ReturnType is especially useful when the mapper function is the real source of truth for a UI shape.
If the function later adds a property like displayStatus, the returned type updates with it.
Full Example
type TaskStatus = "todo" | "in-progress" | "done";
type TaskPriority = "low" | "medium" | "high";
type Task = {
readonly id: number;
readonly createdAt: string;
title: string;
description?: string;
status: TaskStatus;
priority: TaskPriority;
assignedTo?: string;
dueDate?: string;
};
type CreateTaskInput = Omit<Task, "id" | "createdAt">;
type UpdateTaskInput = Partial<CreateTaskInput>;
const createTask: CreateTaskInput = {
title: "Write Angular Tests",
status: "todo",
priority: "high",
};
const updateTask: UpdateTaskInput = {
status: "done",
};
type TaskFormDraft = Partial<CreateTaskInput>;
type ValidatedTaskForm = Required<CreateTaskInput>;
const formDraft: TaskFormDraft = {
title: "Draft title",
};
const validTaskForm: ValidatedTaskForm = {
title: "The Form",
description: "A valid task form.",
status: "in-progress",
priority: "low",
dueDate: "2026-08-23",
assignedTo: "Chris",
};
type TaskCardViewModel = Pick<Task, "id" | "title" | "status" | "priority">;
type ReadonlyTaskCard = Readonly<TaskCardViewModel>;
const card: ReadonlyTaskCard = {
id: 1,
title: "Write Angular Tests",
status: "todo",
priority: "high",
};
// card.title = 'Angular Tests';
type TaskStatusLabels = Record<TaskStatus, string>;
type TaskPriorityLabels = Record<TaskPriority, string>;
const statusLabels: TaskStatusLabels = {
todo: "To Do",
"in-progress": "In Progress",
done: "Done",
};
const priorityLabels: TaskPriorityLabels = {
low: "Low",
medium: "Medium",
high: "High",
};
function mapTaskToCard(task: Task) {
return {
id: task.id,
title: task.title,
status: task.status,
priority: task.priority,
isOverdue: Boolean(task.dueDate && task.status !== "done"),
};
}
type TaskCard = ReturnType<typeof mapTaskToCard>;
const mappedCard: TaskCard = mapTaskToCard({
id: 33,
createdAt: "2026-08-22",
title: "My Task",
description: "The task I must do.",
priority: "medium",
status: "todo",
assignedTo: "Chris",
});
Common Things to Pay Attention To
Utility types should match the meaning of the model
Just because a utility type can transform something does not mean it is always the best business model.
For example:
type ValidatedTaskForm = Required<CreateTaskInput>;
This makes all fields required, including fields that were originally optional.
That may be correct for some forms, but too strict for others.
Record keys follow JavaScript object syntax
This works:
const statusLabels = {
todo: "To Do",
done: "Done",
};
This needs quotes because of the hyphen:
const statusLabels = {
"in-progress": "In Progress",
};
Keys with hyphens, spaces, or special characters need quotes.
Utility types reduce duplication, but they do not replace thinking
A utility type helps create a related model, but the developer still needs to choose the right relationship.
Ask:
Do I need all fields?
Do I need only some fields?
Do I need to remove system fields?
Do I need everything optional?
Do I need everything readonly?
Do I need a label for every union value?
The answer tells you which utility type fits.
Why This Matters in Angular
Angular applications often deal with the same domain data in many layers.
A task might appear as:
API response
component state
form value
create payload
update payload
card view model
table row view model
readonly display model
Without utility types, it is easy to copy-paste similar interfaces everywhere.
That creates type drift.
Type drift happens when related types slowly become inconsistent because one type gets updated and another one does not.
Utility types help prevent that by keeping related shapes tied to the source model.
For example:
type CreateTaskInput = Omit<Task, "id" | "createdAt">;
type UpdateTaskInput = Partial<CreateTaskInput>;
type TaskCardViewModel = Pick<Task, "id" | "title" | "status" | "priority">;
These types communicate intent:
CreateTaskInput removes system fields.
UpdateTaskInput allows partial changes.
TaskCardViewModel contains only the card fields.
That makes the code easier to read, safer to refactor, and clearer for teammates.
Interview-Ready Explanation
Utility types are better than copy-pasting similar interfaces because they let us derive related types from one source domain model. Omit can create a new type by removing properties, such as excluding id and createdAt from a create payload. Partial can make properties optional, which is useful for update payloads where only some fields may change. Pick can create smaller view models by selecting only the fields the UI needs. This reduces duplication, keeps related types connected, and makes refactoring safer because the derived types stay tied to the original model instead of drifting apart.
Final Takeaway
Utility types are not just shortcuts.
They are a way to model relationships between types.
Instead of copying a domain model into several similar interfaces, use utility types to describe how each related shape is derived.
Use:
Omit when removing fields
Partial when making fields optional
Pick when selecting fields
Required when making all fields required
Readonly when preventing reassignment
Record when mapping every union key to a value
ReturnType when deriving a type from a function result
The deeper lesson is this:
Utility types help communicate intent by keeping related models connected to the original source of truth.
