- Book: TypeScript Essentials — From Working Developer to Confident TS
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You add a field to your User type. A passwordHash, say. The
type compiles. The API still returns the whole object. Three
weeks later a frontend dev logs the user object in a debug
panel, and now there is a password hash in a screenshot in a
support ticket.
The fix was never a careful audit of every place a user gets
serialised. It was one type, derived once, that the compiler
keeps honest. TypeScript ships that type in the box. It is
called Omit, and it is one of a small handful of utility
types that do most of the day-to-day work. The three you reach
for first are Pick, Omit, and Record. The two that bite
you are Partial and Required.
One source of truth
Start with one type and derive everything else from it. The
worst version of a codebase has a User, a UserDTO, a
UserResponse, a PublicUser, and a UserUpdate, all
hand-written, all drifting out of sync the moment someone adds
a field to one of them.
Here is the source of truth.
interface User {
id: string;
email: string;
displayName: string;
passwordHash: string;
createdAt: Date;
lastLoginAt: Date | null;
}
Every other shape the app needs is a projection of this one.
Pick: the allowlist view
Pick<T, K> builds a type from T with only the keys you
name. It is an allowlist. You say what stays.
type UserCard = Pick<
User,
"id" | "displayName"
>;
// { id: string; displayName: string }
Reach for Pick when the safe set is small and the dangerous
set is large. A user card needs two fields out of six. Naming
the two you want is shorter and safer than naming the four you
want gone, because the next field someone adds is excluded by
default. If they add socialSecurityNumber to User, your
card type does not silently grow to include it.
Omit: the denylist view
Omit<T, K> is the inverse. It keeps everything except the
keys you name. It is a denylist.
type PublicUser = Omit<
User,
"passwordHash"
>;
// id, email, displayName, createdAt, lastLoginAt
Reach for Omit when most fields are safe and one or two are
not. The password-hash leak from the opening becomes a
compile-time error the moment you type your API response as
PublicUser and try to assign a raw User into a field that
expects the hash gone.
There is a sharp edge worth knowing. Omit does not check that
the key you are removing exists on T. This compiles clean:
type Oops = Omit<User, "passwrdHash">;
// no error — typo silently removes nothing
Pick does check. Pick<User, "passwrdHash"> is a compile
error. That asymmetry is one more reason to prefer Pick for
the security-sensitive views: a typo fails loud instead of
leaking a field you meant to strip.
Record: the typed map
Record<K, V> builds an object type with keys of type K and
values of type V. It is how you type a lookup table without
writing the keys out by hand.
The common mistake is typing a map as { [key: string]: V }
and losing the key information. Record keeps it when the keys
are a finite union.
type Role = "admin" | "editor" | "viewer";
const permissions: Record<Role, string[]> = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"],
};
The win is exhaustiveness. Add "owner" to Role and the
permissions object fails to compile until you add the
owner entry. The map cannot fall behind the union. With a
plain index signature you would get no warning and a runtime
undefined the first time someone with the new role logs in.
Record also reads better than the index-signature form when
the value type is anything past a primitive.
type FeatureFlag = Record<string, {
enabled: boolean;
rolloutPct: number;
}>;
Use Record<string, V> when keys are open-ended (anything goes)
and Record<Union, V> when they are fixed and you want the
compiler to enforce every case.
Composing them
The real value shows up when you stack them. These utility
types are functions over types, and they compose like
functions.
// A user update: every field optional,
// but you can never change the id or the hash.
type UserUpdate = Partial<
Omit<User, "id" | "passwordHash" | "createdAt">
>;
Read it inside out. Drop the three immutable fields, then make
the rest optional. One line, derived from User, and it tracks
User automatically. Add a phoneNumber field to User and
UserUpdate picks it up as an optional field with no extra
work.
A settings page that edits only a slice:
type ProfileForm = Pick<
User,
"displayName" | "email"
>;
type ProfileDraft = Partial<ProfileForm>;
The Partial gotcha
Partial<T> makes every field optional. That sounds harmless
and it is the most misused utility type in the language.
The trap is using Partial on a value that the rest of your
code assumes is complete. A function that takes
Partial<Config> is telling every caller that every field
might be missing, so every read inside that function has to
cope with undefined.
interface Config {
host: string;
port: number;
retries: number;
}
function connect(cfg: Partial<Config>) {
// cfg.host is string | undefined
const url = `${cfg.host}:${cfg.port}`;
// "undefined:undefined" at runtime if you
// forget to check
}
Partial belongs on the input edge, where a caller genuinely
supplies a subset, like an update payload or a set of
overrides. It does not belong on the value your code reads
from. The usual pattern is to take a Partial and merge it
into a complete default before anyone reads it.
const defaults: Config = {
host: "localhost",
port: 5432,
retries: 3,
};
function makeConfig(
overrides: Partial<Config>,
): Config {
return { ...defaults, ...overrides };
}
The function takes a Partial and returns a full Config.
Everything downstream reads a complete object. The optionality
lives only at the boundary where it is true.
The Required gotcha
Required<T> is the mirror image: it strips ? off every
field. The same warning applies in reverse. It does not add
missing values, it only changes the type. If you assert a
Partial as Required without actually filling the fields,
you have lied to the compiler.
type RawConfig = Partial<Config>;
function load(raw: RawConfig): Config {
// WRONG: no runtime fill, just a cast
return raw as Required<RawConfig>;
// compiles, but raw.port may be undefined
}
Required is honest only when the values are present at
runtime. There is also a quieter footgun: Required<T> does
not remove undefined from a field that was typed
field: string | undefined without a ?. It only flips the
optional marker. The two look the same in editor tooltips and
behave differently.
Putting it together: a typed config from one source
Here is the whole pattern end to end. One source of truth, then
the views fall out.
interface AppConfig {
env: "dev" | "staging" | "prod";
host: string;
port: number;
featureFlags: Record<string, boolean>;
}
// What a caller may override:
type ConfigOverrides = Partial<
Omit<AppConfig, "env">
>;
const base: AppConfig = {
env: "dev",
host: "localhost",
port: 8080,
featureFlags: {},
};
function buildConfig(
env: AppConfig["env"],
overrides: ConfigOverrides = {},
): AppConfig {
return { ...base, env, ...overrides };
}
const prod = buildConfig("prod", {
host: "api.example.com",
port: 443,
});
env is fixed by the function argument, never overridable.
host and port are open to overrides. featureFlags stays a
typed Record. Every shape in this file derives from
AppConfig. Change one field there and the overrides type, the
base value, and the builder all update or fail to compile. No
parallel type drifts.
That is the whole game with utility types. Pick the smallest
set of source-of-truth interfaces, then express every other
shape as a derivation. Pick and Omit give you views,
Record gives you typed maps, and Partial and Required
shift optionality at the boundaries where it belongs. The
hand-written UserDTO next to User next to UserUpdate was
always going to drift. The derived version cannot.
If this was useful
These five utility types are part of the daily surface area
TypeScript Essentials walks through, alongside narrowing,
modules, and the rest of the machinery you touch every day. If
the source-of-truth pattern above clicked, The TypeScript Type
System takes the same idea further, into the mapped and
conditional types that Pick, Omit, and Record are built
out of under the hood.
The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
- Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
- PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)