Cutting TypeScript Boilerplate with Mapped Types: A Practical Pattern
Stop duplicating mutable, read‑only, and partial versions of the same TypeScript interface. Use built‑in mapped types like Readonly<T> and Partial<T> to derive variants from a single source, keeping definitions in sync and cutting boilerplate.
22 Jul 2026, 02:37 UTC

The problem: duplicated shapes across a codebase
In a growing TypeScript project, you often end up with three versions of the same data shape: the canonical mutable interface, a read‑only variant for API responses, and a partial variant for form handling or patch requests. Each version lives in its own file, and when a field changes — say email becomes required — you have to update every copy. Miss one, and the compiler won’t catch the drift until runtime validation fails.
The takeaway: TypeScript’s built‑in mapped types (Readonly<T>, Partial<T>, Pick<T, K>, Omit<T, K>, Record<K, T>) let you derive those variants from a single source‑of‑truth type, keeping definitions in sync and eliminating repetitive declarations.
Why manual duplication fails
Teams typically start with a straightforward interface:
interface User {
id: number;
name: string;
email?: string;
}
Soon a read‑only version appears for immutable state:
interface ReadonlyUser {
readonly id: number;
readonly name: string;
readonly email?: string;
}
And a partial version for PATCH endpoints or form drafts:
interface PartialUser {
id?: number;
name?: string;
email?: string;
}
Every new field or modifier change requires editing three files. The risk isn’t just extra keystrokes — it’s that the three definitions can silently diverge, especially when different owners maintain them.
Deriving variants with mapped types
TypeScript’s utility types operate at the type level. They take an existing type and produce a new one by transforming each property. The two most common are:
Readonly<T>— addsreadonlyto every property ofT.Partial<T>— makes every property ofToptional.
Because they’re mapped types, they preserve the original keys and only modify the property descriptors. You declare them once alongside the source interface:
interface User {
id: number;
name: string;
email?: string;
}
type ReadonlyUser = Readonly<User>;
type PartialUser = Partial<User>;
The compiler expands these to:
// ReadonlyUser
{
readonly id: number;
readonly name: string;
readonly email?: string;
}
// PartialUser
{
id?: number;
name?: string;
email?: string;
}
Now a function that promises not to mutate its argument can accept ReadonlyUser, and a PATCH handler can type its request body as PartialUser. If User gains a role field, both derived types update automatically.
Worked example: API layer and UI component
Consider a small Express‑style handler and a React component that share the same domain model.
// types/user.ts
export interface User {
id: number;
name: string;
email?: string;
}
export type ReadonlyUser = Readonly<User>;
export type PartialUser = Partial<User>;
// handlers/user.ts
import { ReadonlyUser, PartialUser } from '../types/user';
export function getUser(id: number): ReadonlyUser {
const row = db.select('*').from('users').where({ id }).first();
// row matches User; returning as ReadonlyUser signals immutability
return row as ReadonlyUser;
}
export function patchUser(id: number, changes: PartialUser): User {
// changes has all properties optional
return db('users').where({ id }).update(changes).returning('*')[0];
}
// components/UserForm.tsx
import { PartialUser } from '../types/user';
function UserForm({ initial }: { initial: PartialUser }) {
// initial may have any subset of fields
const [values, setValues] = useState<PartialUser>(initial);
// ...
}
Hovering over ReadonlyUser or PartialUser in an IDE shows the expanded shape, confirming the transformation without extra documentation.
Trade‑offs and limitations
Type‑checking performance
Mapped types are cheap at shallow depth, but deeply nested mappings — especially combined with conditional types or generics — can noticeably increase type‑checking time. A practical guard is to run the compiler with --extendedDiagnostics before and after introducing a complex mapped type:
# Run in the project root (requires Node and TypeScript installed)
npx tsc --noEmit --extendedDiagnostics 2>&1 | grep -E "(Types|Instantiations|Memory)"
Compare the Types and Instantiations counts. If they jump significantly, consider flattening the hierarchy or extracting intermediate type aliases.
Readability in tooltips
When you hover over a heavily mapped type, the tooltip may show a wall of expanded properties. Keep nesting to one or two levels, and add a comment explaining the intent:
// Public API surface — all fields immutable
export type PublicUser = Readonly<Pick<User, 'id' | 'name' | 'email'>>;
Runtime validation is separate
Mapped types exist only at compile time. They don’t generate validators, serializers, or database schemas. If you use Zod, io‑ts, or similar, you still need a runtime schema that mirrors the same shape. A common pattern is to define the Zod schema first and infer the TypeScript type from it, then apply mapped types for variants.
Adopting the pattern in a team
- Enable strict mode. In
tsconfig.json, set"strict": true. This catches accidental mutation ofReadonlyproperties and missing properties inPartialcontexts. - Standardize on utility types. Add a lint rule (e.g.,
@typescript-eslint/prefer-readonly) and document the preferred aliases in a shared style guide:Readonly<T>,Partial<T>,Pick<T, K>,Omit<T, K>. - Colocate derived types. Keep the source interface and its mapped variants in the same file (e.g.,
types/user.ts) so reviewers see the relationship immediately. - Verify with a quick compile. Run
npx tsc --noEmit --strictin CI. Introduce an intentional error — removereadonlyfrom a property in a function expectingReadonlyUser— and confirm the build fails.
Closing: make mapped types the default, not the exception
When a new domain model appears, start with a single interface and derive Readonly, Partial, Pick, or Omit variants as needed. The compiler keeps them consistent, the team avoids copy‑paste drift, and the type signatures themselves document the intended mutability and completeness. Reserve manual duplication for the rare cases where the derived shape genuinely differs — like adding a computed field that doesn’t exist in the source. With strict mode on and a short style‑guide entry, the pattern becomes a low‑effort habit that pays off every time the schema evolves.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.