TypeScript · lesson 10 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2Utility Types in TypeScript
Partial, Required, Readonly, Pick, Omit, Record, Exclude, NonNullable, ReturnType, Parameters and Awaited, each shown with real tsc errors and tsx output.
By SkillByExample · Updated
Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. TypeScript 6 is strict by default — on 5.x add --strict to get the same errors.
Utility types are generic type aliases that ship with TypeScript. Each one transforms a type you already have into a related one, so a single source-of-truth interface can produce the shapes for creating, updating and displaying the same data.
Partial and Required
Partial<T> makes every property optional — the shape of a patch. Required<T> makes every property mandatory — the shape after defaults are applied.
interface User { id: number; name: string; email: string }
function updateUser(user: User, changes: Partial<User>): User {
return { ...user, ...changes };
}
interface Config { apiUrl?: string; timeout?: number; retries?: number }
function withDefaults(raw: Config): Required<Config> {
return { apiUrl: raw.apiUrl ?? "http://localhost", timeout: raw.timeout ?? 5000, retries: raw.retries ?? 3 };
}
const alice: User = { id: 1, name: "Alice", email: "alice@example.com" };
console.log(updateUser(alice, { name: "Alicia" }));
const config = withDefaults({ timeout: 100 });
console.log(config, config.retries + 1);
updateUser(alice, { nickname: "Al" });
const partialConfig: Required<Config> = { apiUrl: "x" };
$ npx tsc --noEmit --module nodenext partial.ts
partial.ts(18,21): error TS2353: Object literal may only specify known properties, and 'nickname' does not exist in type 'Partial<User>'.
partial.ts(19,7): error TS2739: Type '{ apiUrl: string; }' is missing the following properties from type 'Required<Config>': timeout, retries
Partial still only allows the keys User has, so nickname is rejected. The Required<Config> return type is why config.retries + 1 needs no undefined check. Remove the two bad lines:
$ npx tsx partial.ts
{ id: 1, name: 'Alicia', email: 'alice@example.com' }
{ apiUrl: 'http://localhost', timeout: 100, retries: 3 } 4
One trap: Partial<User> also allows { name: undefined }, and spreading that overwrites name with undefined. The option exactOptionalPropertyTypes (on in TypeScript 6’s tsc --init, off under plain strict) makes the compiler reject an explicit undefined for an optional property.
Readonly
Readonly<T> marks every property readonly — handy on parameters to promise you won’t mutate the caller’s object.
interface Point { x: number; y: number }
interface Shape { name: string; origin: Point }
function translate(p: Readonly<Point>, dx: number): Point {
p.x += dx;
return { ...p, x: p.x + dx };
}
const shape: Readonly<Shape> = { name: "dot", origin: { x: 0, y: 0 } };
shape.name = "blob";
shape.origin.x = 99;
console.log(translate({ x: 1, y: 2 }, 3), shape);
$ npx tsc --noEmit --module nodenext readonly.ts
readonly.ts(5,5): error TS2540: Cannot assign to 'x' because it is a read-only property.
readonly.ts(10,7): error TS2540: Cannot assign to 'name' because it is a read-only property.
Two errors, not three: shape.origin.x = 99 is allowed, because Readonly is shallow — it made origin read-only, not the object inside it. It’s also compile-time only: Object.freeze is the runtime equivalent, and it’s shallow too.
Pick and Omit
Pick<T, K> keeps the listed keys; Omit<T, K> keeps everything else.
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
type PublicUser = Pick<User, "id" | "name">;
type CreateUserDto = Omit<User, "id" | "createdAt">;
function toPublic(user: User): PublicUser {
return { id: user.id, name: user.name };
}
const dto: CreateUserDto = { name: "Bob", email: "bob@example.com", password: "s3cret" };
const user: User = { ...dto, id: 2, createdAt: new Date(0) };
console.log(toPublic(user));
const leaky: PublicUser = user;
console.log(leaky);
type Typo1 = Pick<User, "passwrd">;
type Typo2 = Omit<User, "passwrd">;
$ npx tsc --noEmit --module nodenext pick-omit.ts
pick-omit.ts(23,25): error TS2344: Type '"passwrd"' does not satisfy the constraint 'keyof User'.
Pick checks its keys against User, but Omit accepts any string, so Omit<User, "passwrd"> silently removes nothing. Remove the two Typo lines and run it:
$ npx tsx pick-omit.ts
{ id: 2, name: 'Bob' }
{
name: 'Bob',
email: 'bob@example.com',
password: 's3cret',
id: 2,
createdAt: 1970-01-01T00:00:00.000Z
}
The second line of output is the important one. Assigning a full User to a PublicUser variable compiles — a User has id and name, and extra properties are fine outside a fresh object literal — but the value still carries password. Pick narrows the type; only code like toPublic removes the data.
Record
Record<K, V> is an object type with keys K and values V. With a union of literal keys it’s exhaustive.
type Role = "admin" | "editor" | "viewer";
const permissions: Record<Role, string[]> = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"],
};
function can(role: Role, action: string): boolean {
return permissions[role].includes(action);
}
console.log(can("editor", "write"), can("viewer", "delete"));
const labels: Record<Role, string> = { admin: "Administrator", editor: "Editor" };
$ npx tsc --noEmit --module nodenext record.ts
record.ts(14,7): error TS2741: Property 'viewer' is missing in type '{ admin: string; editor: string; }' but required in type 'Record<Role, string>'.
Add a fourth role to Role and every Record<Role, ...> in the codebase reports the missing entry. Record<string, V> is the open-ended version — equivalent to an index signature.
Exclude, Extract and NonNullable
These filter unions: Exclude removes members assignable to a type, Extract keeps them, NonNullable removes null and undefined.
type Status = "active" | "inactive" | "banned" | "deleted";
type Visible = Exclude<Status, "banned" | "deleted">;
type Moderated = Extract<Status, "banned" | "deleted" | "archived">;
function assertDefined<T>(value: T, label: string): NonNullable<T> {
if (value == null) throw new Error(`${label} is missing`);
return value;
}
const port = assertDefined(process.env.PORT ?? "3000", "PORT");
console.log(port.padStart(6, "."));
try {
assertDefined(process.env.MISSING_VAR, "MISSING_VAR");
} catch (e) {
console.log((e as Error).message);
}
const v: Record<Visible, true> = {};
const m: Record<Moderated, true> = {};
$ npx tsc --noEmit --module nodenext --types node filter-unions.ts
filter-unions.ts(18,7): error TS2739: Type '{}' is missing the following properties from type 'Record<Visible, true>': active, inactive
filter-unions.ts(19,7): error TS2739: Type '{}' is missing the following properties from type 'Record<Moderated, true>': deleted, banned
The last two lines are deliberate probes: a Record keyed by a union needs one property per member, so assigning {} makes tsc list the members. Visible is "active" | "inactive", and Moderated is only "banned" | "deleted" — Extract keeps members of Status, and "archived" was never one. assertDefined returns value with no cast: after the == null check TypeScript narrows T to NonNullable<T> by itself. (--types node loads Node’s type definitions for process; TypeScript 6 no longer includes installed @types packages automatically.) Remove the last two lines:
$ npx tsx filter-unions.ts
..3000
MISSING_VAR is missing
ReturnType, Parameters and Awaited
These read types off functions, so the function stays the single source of truth.
function createStore(initial: number, label: string) {
let count = initial;
return {
label,
increment: () => ++count,
get count() { return count; },
};
}
async function loadUser(id: number) {
return { id, name: "Alice", roles: ["admin"] };
}
export type Store = ReturnType<typeof createStore>;
export type StoreArgs = Parameters<typeof createStore>;
export type LoadedUser = Awaited<ReturnType<typeof loadUser>>;
export const args: StoreArgs = [10, "clicks"];
export const store: Store = createStore(...args);
export const user: LoadedUser = await loadUser(1);
store.increment();
console.log(store.label, store.count, user.roles);
$ npx tsc --noEmit --module nodenext function-types.ts
$ npx tsx function-types.ts
clicks 11 [ 'admin' ]
Parameters gives a tuple, so args can be spread straight back into createStore with full checking. ReturnType of an async function is a Promise<...>; Awaited unwraps it — the combination Awaited<ReturnType<typeof fn>> is the standard way to name “what this async function resolves to”. Probing with wrong values shows what each one resolved to:
function createStore(initial: number, label: string) {
let count = initial;
return { label, increment: () => ++count, get count() { return count; } };
}
async function loadUser(id: number) {
return { id, name: "Alice", roles: ["admin"] };
}
const s: ReturnType<typeof createStore> = {};
const a: Parameters<typeof createStore> = ["10"];
const u: Awaited<ReturnType<typeof loadUser>> = { id: 1 };
const p: ReturnType<typeof loadUser> = { id: 1, name: "Alice", roles: [] };
$ npx tsc --noEmit --module nodenext probe.ts
probe.ts(9,7): error TS2739: Type '{}' is missing the following properties from type '{ label: string; increment: () => number; readonly count: number; }': label, increment, count
probe.ts(10,44): error TS2322: Type 'string' is not assignable to type 'number'.
probe.ts(11,7): error TS2739: Type '{ id: number; }' is missing the following properties from type '{ id: number; name: string; roles: string[]; }': name, roles
probe.ts(12,42): error TS2353: Object literal may only specify known properties, and 'id' does not exist in type 'Promise<{ id: number; name: string; roles: string[]; }>'.
ReturnType expanded to the object literal type, getter included as readonly count. Parameters is a tuple whose first element is a number, so "10" fails in that position. And the last line confirms that without Awaited you’re holding a Promise, not the user.
A Worked Example: One Interface, Many Shapes
Deriving every API shape from one interface means a field added to User shows up where it should — and nowhere it shouldn’t.
interface User {
id: number;
name: string;
email: string;
password: string;
role: "admin" | "user";
createdAt: Date;
}
type ServerFields = "id" | "createdAt";
type CreateUser = Omit<User, ServerFields>;
type UpdateUser = Partial<Omit<User, ServerFields>>;
type UserResponse = Readonly<Omit<User, "password">>;
const db: User[] = [];
function create(input: CreateUser): UserResponse {
const user: User = { ...input, id: db.length + 1, createdAt: new Date(0) };
db.push(user);
const { password, ...response } = user;
return response;
}
function update(id: number, patch: UpdateUser): UserResponse | undefined {
const user = db.find((u) => u.id === id);
if (!user) return undefined;
Object.assign(user, patch);
const { password, ...response } = user;
return response;
}
console.log(create({ name: "Alice", email: "a@x.io", password: "pw", role: "user" }));
console.log(update(1, { role: "admin" }));
update(1, { id: 7 });
$ npx tsc --noEmit --module nodenext derived.ts
derived.ts(34,13): error TS2353: Object literal may only specify known properties, and 'id' does not exist in type 'Partial<Omit<User, ServerFields>>'.
id isn’t part of UpdateUser, so a patch can’t change it. Remove the last line:
$ npx tsx derived.ts
{
name: 'Alice',
email: 'a@x.io',
role: 'user',
id: 1,
createdAt: 1970-01-01T00:00:00.000Z
}
{
name: 'Alice',
email: 'a@x.io',
role: 'admin',
id: 1,
createdAt: 1970-01-01T00:00:00.000Z
}
The destructuring const { password, ...response } = user is what actually strips the password; the UserResponse return type is what guarantees you didn’t forget to.
Practice
1. Write type StrictOmit<T, K extends keyof T> = Omit<T, K> and show it catches the "passwrd" typo that Omit let through.
Solution
type StrictOmit<T, K extends keyof T> = Omit<T, K>;
interface User { id: number; name: string; password: string }
type Safe = StrictOmit<User, "password">;
type Typo = StrictOmit<User, "passwrd">;
const s: Safe = { id: 1, name: "Alice" };
console.log(s);
$ npx tsc --noEmit --module nodenext practice-1.ts
practice-1.ts(6,30): error TS2344: Type '"passwrd"' does not satisfy the constraint 'keyof User'.
The constraint K extends keyof T is the only difference from the built-in, and it’s enough to reject unknown keys.
2. Build a Record from each Status to a colour, then add "archived" to Status. What does tsc report?
Solution
type Status = "active" | "inactive" | "archived";
const colours: Record<Status, string> = {
active: "green",
inactive: "gray",
};
console.log(colours);
$ npx tsc --noEmit --module nodenext practice-2.ts
practice-2.ts(3,7): error TS2741: Property 'archived' is missing in type '{ active: string; inactive: string; }' but required in type 'Record<Status, string>'.
The new member of the union becomes a required key of the record — the compiler finds every table that needs updating.
3. Use Parameters and ReturnType to write a logged wrapper that prints a function’s arguments and result without losing its types.
Solution
function logged<F extends (...args: any[]) => any>(name: string, fn: F) {
return (...args: Parameters<F>): ReturnType<F> => {
const result = fn(...args);
console.log(`${name}(${args.join(", ")}) = ${result}`);
return result;
};
}
const add = logged("add", (a: number, b: number) => a + b);
const total: number = add(2, 3);
console.log(total * 10);
add("2", 3);
$ npx tsc --noEmit --module nodenext practice-3.ts
practice-3.ts(12,5): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
The wrapper has the same parameter list as add, so the string argument is still caught. Remove the last line and it prints add(2, 3) = 5 then 50:
$ npx tsx practice-3.ts
add(2, 3) = 5
50