What you'll learn
Quick Answer
Utility types are generic helpers built into TypeScript that derive one type from another, so you write a shape once. Partial makes every property optional, Required does the reverse, Pick and Omit select or drop keys, Record builds a key-value map, Readonly blocks assignment, and ReturnType extracts a function's return type. All of them are erased at compile time, so they cost nothing at runtime. The traps are Omit accepting invalid keys and Record pretending every key exists.
What utility types actually are
Utility types are not magic compiler features. They are ordinary generic types written in TypeScript's own bundled declaration files, and you could write every one of them yourself. Partial, for example, is about one line:
type Partial<T> = {
[K in keyof T]?: T[K];
};That is a mapped type. It walks every key of T and re-declares it as optional. Knowing this matters because it tells you what utility types are for: deriving a second shape from a single source of truth, instead of hand-writing a near-identical interface that slowly drifts out of sync with the first one.
The classic use for Partial is a PATCH-style update, where the caller sends only the fields that changed.
interface User {
id: string;
name: string;
email: string;
city: string;
}
function updateUser(id: string, patch: Partial<User>) {
return fetch(`/api/users/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
}
updateUser("u_1", { city: "Pune" }); // fine
updateUser("u_1", {}); // also fine, and does nothingNote the second call. Partial makes every field optional, which means the empty object is a valid argument. If your API treats an empty patch as a no-op you get a silent nothing; if it treats it as a full replacement you have just wiped a row. Partial says "any subset, including none", and only you know whether none is acceptable.
Required is the mirror image and is genuinely useful at the point where defaults get applied. Options come in with holes; after you fill them, the type should say so.
interface Options {
retries?: number;
timeoutMs?: number;
}
function withDefaults(o: Options): Required<Options> {
return {
retries: o.retries ?? 3,
timeoutMs: o.timeoutMs ?? 5000,
};
}Everything downstream of withDefaults now reads config.retries without a null check, and the compiler enforces that you actually supplied a default for every option. Add a new optional field to Options and withDefaults breaks immediately, which is the point.
Pick and Omit, and the typo that compiles
Pick keeps the keys you name. Omit drops them. They look like a symmetric pair, and they are not, because only one of them checks your spelling.
type PublicUser = Pick<User, "id" | "name" | "city">;
type SafeUser = Omit<User, "email">;
type Broken1 = Pick<User, "emial">; // Error: "emial" is not a key of User
type Broken2 = Omit<User, "emial">; // No error. Result is the whole User.The reason is in the definitions. Pick<T, K extends keyof T> constrains K to real keys, so a typo is rejected. Omit is defined roughly as Pick<T, Exclude<keyof T, K>> with K only constrained to keyof any, meaning any string, number or symbol. Exclude then filters out a key that was never in the list, removes nothing, and hands back the full type. Your code compiles, your API response still contains the password hash, and nothing anywhere told you.
This is not a theoretical problem. The most common real use of Omit is stripping a sensitive or server-generated field before sending an object outward, which is exactly the case where failing silently is worst. If you rename passwordHash to password_hash in the interface, every Omit<User, "passwordHash"> in the codebase quietly stops omitting anything.
The fix takes one line and belongs in whatever shared types file you already have:
type StrictOmit<T, K extends keyof T> = Omit<T, K>;
type Checked = StrictOmit<User, "emial">; // Error, as it should beBeyond safety, the everyday use of these two is building form and request types from the model you already have. A create-user form needs everything except the server-assigned id, and the response adds fields the form never had:
type CreateUserInput = Omit<User, "id">;
type UserRow = User & { createdAt: string; updatedAt: string };
function createUser(input: CreateUserInput): Promise<UserRow> {
// ...
}One interface, three derived shapes, and adding a field to User updates all of them. That is the entire argument for utility types over copy-paste interfaces.
Record, and the keys it promises you
Record<K, V> builds an object type with keys K and values V. It has two completely different uses, and only one of them is safe by default.
The good use is an exhaustive lookup keyed by a union. Here the compiler becomes a checklist:
type Plan = "free" | "pro" | "campus";
const priceInRupees: Record<Plan, number> = {
free: 0,
pro: 199,
campus: 4999,
};Add "school" to Plan and this object immediately fails to compile with a missing-property error. That is a small thing that pays for itself the first time someone adds a plan and forgets the pricing table, the label map and the feature list. Every one of those maps should be a Record keyed by the union rather than a loose object.
The other use is an open dictionary, and this is where Record lies to you:
const bySlug: Record<string, User> = {};
bySlug["rahul"].name; // compiles happily. Throws at runtime.Record<string, User> literally means every string key holds a User. TypeScript has no way to know which keys are actually present, so by default it assumes the type is honest and lets you dereference the result. You get Cannot read properties of undefined (reading 'name') in production from code that type-checked cleanly.
There are two fixes. The blunt one is to be honest in the type:
const bySlug: Record<string, User | undefined> = {};
bySlug["rahul"]?.name; // now optional chaining is required
const u = bySlug["rahul"];
if (u) console.log(u.name); // or narrow it properlyThe systematic one is the noUncheckedIndexedAccess compiler flag, which adds | undefined to every index access across the project, including array elements. It is noisy on an existing codebase and it is correct. Turn it on in a new project and you never write this bug.
If the keys are genuinely dynamic and you add and delete them at runtime, a Map<string, User> is usually the better tool anyway. map.get() returns User | undefined by definition, so the type system forces the check without any extra configuration, and you avoid the prototype-key surprises that plain objects have.
Readonly is shallow, and it does not survive a function call
Readonly<T> marks every top-level property as readonly. Beginners read that as "frozen". It is neither deep nor enforced across boundaries.
interface Profile {
name: string;
address: { city: string };
}
const p: Readonly<Profile> = {
name: "Asha",
address: { city: "Pune" },
};
p.name = "Meera"; // Error: cannot assign to a read-only property
p.address.city = "Delhi"; // No error. Readonly is one level deep.That first surprise is well known. The second is not, and it is worse:
function rename(x: Profile) {
x.name = "changed";
}
rename(p); // No error at all
console.log(p.name); // "changed"TypeScript deliberately ignores readonly property modifiers when checking whether one object type is assignable to another. So a Readonly<Profile> can be passed anywhere a Profile is expected, and the function on the other side is free to mutate it. Readonly documents intent inside the block where the variable is declared. It does not protect the object from the rest of the program.
Arrays are the exception, and they behave the way you would hope. readonly string[] (which is what ReadonlyArray<string> means) is not assignable to string[], because a function taking string[] could call push. So marking function parameters as readonly T[] is a real, enforced guarantee and costs nothing.
function total(marks: readonly number[]) {
// marks.push(90); // Error: push does not exist on a readonly array
return marks.reduce((a, b) => a + b, 0);
}If you want the deep version, as const on a literal gives you a deeply readonly value with literal types, and Object.freeze gives you the runtime enforcement that Readonly never had. Remember that Object.freeze is also shallow, and that in non-strict-mode JavaScript a write to a frozen object fails silently rather than throwing.
The practical rule: use readonly on function parameters and on config objects for documentation and for the array guarantee, and do not treat it as a security boundary. Anything crossing a real boundary, such as JSON from an API, needs a copy or a validator, not a type modifier.
ReturnType, Parameters and Awaited: stop writing the type twice
The remaining utilities extract types out of functions, which is how you avoid declaring a shape that already exists implicitly.
function getUser(id: string) {
return { id, name: "Asha", city: "Pune" };
}
type User = ReturnType<typeof getUser>;
// { id: string; name: string; city: string }
type IdArg = Parameters<typeof getUser>[0]; // stringThe typeof is not optional and is the single most common mistake here. ReturnType takes a function type, not a function value, so writing ReturnType<getUser> gives you 'getUser' refers to a value, but is being used as a type here. In a type position, typeof someValue means "the type of that value", which is a different operator from the JavaScript typeof you use at runtime.
For async functions the return type is a promise, so unwrap it with Awaited:
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`);
return (await res.json()) as { id: string; name: string };
}
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>;
// { id: string; name: string }Awaited recursively unwraps nested promises, which matters when a function returns Promise<Promise<T>> through some generic plumbing. Writing ReturnType<typeof fetchUser> alone would give you the promise, and then user.name fails with a confusing error about the property not existing on Promise.
There is a trade-off worth naming. Deriving a type from an implementation means the type changes the moment somebody edits that function. For an internal helper that is exactly what you want: one place to change. For a contract that other teams or a public package depends on, invert it. Declare the type explicitly and annotate the function with it, so a careless edit fails at the function rather than silently reshaping every consumer.
Three smaller ones round out the set. NonNullable<T> strips null and undefined. Exclude<T, U> removes union members, so Exclude<Plan, "free"> is the paid plans. Extract<T, U> keeps only the members assignable to U, which is how you pull one variant out of a discriminated union: Extract<Result, { status: "error" }>. None of these emit a single byte of JavaScript. They exist only during compilation and are gone from the build output entirely.
