Skip to content

TypeScript Utility Types I Actually Use (And the Ones I Skip)

TypeScript Utility Types I Actually Use (And the Ones I Skip)

Confession: for about two years I maintained a file called types/user-inputs.ts that was just the User interface copied four times with small edits. CreateUserInput. UpdateUserInput. PublicUser. UserPreview. Every time the real User grew a field, I’d update it and then forget at least one of the copies. The bug would surface weeks later, three layers away, as an API response missing a field that the compiler swore was fine.

The fix had been sitting in the language the whole time. TypeScript utility types take one source-of-truth type and derive the variants, so when the source changes, the variants follow. I knew they existed. I’d just filed them under “fancy type-level stuff I’ll learn later,” next to conditional types and whatever infer does.

This post is the tour I wish someone had given me: the utility types I now use weekly, the hand-rolled junk they replaced, and the clever ones I tried and quietly backed out of.

What utility types actually are

Nothing magic. They’re mapped and conditional types that ship with the compiler, documented on one page of the official handbook. You could write most of them yourself in a line or two. The value isn’t sophistication, it’s that they’re standard: every TypeScript developer who reads Partial<User> knows instantly what it means, and nobody has to review your homemade MakeOptional<T> for correctness.

That standardization argument is what finally sold me. My hand-rolled versions weren’t wrong. They were just one more thing for the next person to read, doubt, and re-verify.

Partial, Pick and Omit do most of the work

Here’s the duplicated-interface mess I used to write:

interface User {
  id: string;
  email: string;
  name: string;
  passwordHash: string;
  createdAt: Date;
}

// the drift-prone copies
interface UpdateUserInput {
  email?: string;
  name?: string;
}

interface PublicUser {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
}

And the derived version that replaced it:

type UpdateUserInput = Partial<Pick<User, "email" | "name">>;
type PublicUser = Omit<User, "passwordHash">;

Two lines, and they can’t drift. Add a field to User and PublicUser picks it up on the next compile. Rename passwordHash and the Omit breaks loudly instead of leaking quietly.

A warning about Partial before you spray it everywhere, because I did and regretted it. Partial<User> makes every field optional, including ones your update logic actually requires. My update endpoint happily accepted {} for months. Nothing crashed. It just did nothing, successfully, with a 200 status. The pattern that fixed it: keep Partial for the fields that are legitimately optional and intersect the required ones back in.

type UpdateUserInput = Partial<Pick<User, "email" | "name">> & {
  id: string; // you can't update nothing and nobody
};

Partial is a scalpel. Used on a narrow Pick, it’s precise. Used on a whole entity, it tells the compiler “anything goes,” and the compiler believes you.

One habit worth stealing: prefer Pick over Omit for anything security-adjacent. Omit is a denylist. If someone adds a resetToken field to User next quarter, Omit<User, "passwordHash"> will happily include it in your public type. Pick is an allowlist, so new fields stay private until you opt them in. I learned that one the embarrassing way, though thankfully in a code review rather than an incident report.

Record, and the index signature it replaced

I used to type lookup objects like this:

const statusColors: { [key: string]: string } = {
  active: "green",
  suspended: "amber",
  deleted: "red",
};

The index signature accepts any string, which means statusColors.actve compiles fine and returns undefined at runtime. The Record version closes that hole:

type Status = "active" | "suspended" | "deleted";

const statusColors: Record<Status, string> = {
  active: "green",
  suspended: "amber",
  deleted: "red",
};

Now a typo’d key is a compile error, and so is a missing one. Add "banned" to the Status union and the compiler walks you to every lookup table that needs the new entry. It turns “did I update everything?” from a memory exercise into a build failure, and my memory loses that contest often enough that I’ll take the trade every time.

ReturnType and Awaited, for code you don’t control

The workhorses above shine on your own types. This pair shines on everyone else’s. Some library exports a function but not the type of what it returns. Old me would reconstruct that shape by hand, fifteen lines of interface transcribed from dumped output, stale the moment the library updated.

// the type the library never exported
type Session = Awaited<ReturnType<typeof auth.getSession>>;

ReturnType extracts what the function returns, Awaited unwraps the promise, and the result tracks the library exactly. When the package updates and the shape changes, my code breaks at compile time instead of in production. I use the same trick on my own database query helpers, where the row shape lives in one query function and everything downstream derives from it.

Parameters is the same idea pointed at inputs, and it quietly cleaned up my test files. I had test helpers that re-declared a service function’s argument types, which meant every signature change broke the tests in the dumbest possible way: not because behavior changed, but because the helper’s copy of the types went stale.

function buildOrderArgs(
  overrides: Partial<Parameters<typeof createOrder>[0]> = {}
) {
  return { customerId: "test-1", items: [], ...overrides };
}

Now the helper’s types are the function’s types. Change createOrder and the helpers either keep working or fail at the exact line that needs attention. My test maintenance time on that service dropped enough that I went back and did the same thing to two other projects the same week. I wrote about a related habit in my satisfies post, and the theme is the same: stop telling the compiler things it already knows.

The ones I tried and put back

Honesty section. Not every utility type earned a spot.

Capitalize, Uncapitalize and the string-manipulation family are impressive and I have used them exactly once outside of a toy, generating event-handler names from event names. The type errors they produce when something goes wrong are genuinely hard to read, and a junior teammate lost an afternoon to one of mine. If a plain union written out by hand costs me ten keystrokes and saves the next person that afternoon, the union wins.

NoInfer, added in TypeScript 5.4, solves a real inference problem in generic function signatures. If you maintain a library, learn it. In application code I’ve needed it maybe twice, and both times rearranging my function arguments solved the same problem more legibly.

Deeply nested Omit<Pick<Partial<...>>> chains are the type-level version of clever one-liners. When I find myself three utilities deep, that’s usually the type telling me the underlying model is wrong, and the fix belongs in the data design, not the type gymnastics. The same instinct applies to generics, which I covered in what I actually reach for with TypeScript generics: the cleverness budget is real and it’s smaller than you think.

Something to try this week

Grep your codebase for interfaces that share three or more field names with another interface. Each cluster is a candidate for one source type plus Pick, Omit or Partial derivations. Convert one cluster, then add a field to the source type and watch where the compiler takes you. That single compile error tour usually converts people faster than any blog post, including this one.

I do this audit early on most client projects I take on, and it’s reliably the cheapest win in the codebase: no runtime change, no new dependency, just deleting copies that were waiting to drift.