Utility Types & Type Patterns for .NET Engineers
For .NET engineers who know: C# generics, DTOs,
IReadOnlyList<T>, discriminated unions via class hierarchies, and the Result/Option patterns from functional programming extensions You’ll learn: The TypeScript utility types and type-level patterns that replace common C# patterns — when they’re strictly better, when they’re a trade-off, and when to reach for them in practice Time: 15-20 min read
TypeScript ships a library of built-in generic utility types that transform and compose other types at the type level. If you’ve spent time with C# generics and interfaces, you can read Partial<T> and understand the intent immediately. But the patterns built on top of these utilities — branded types, discriminated unions, the Result pattern — depart from the C# idiom in ways that will surprise you. This article maps every pattern to its C# equivalent and tells you honestly when the TypeScript approach wins and when it costs you something.
The .NET Way (What You Already Know)
In C#, type manipulation is done primarily through interfaces, generics, and class hierarchies. When you want to express “a User with only some fields required,” you create a separate DTO:
// C# — a separate class for each shape you need
public record CreateUserDto(string Name, string Email, string? PhoneNumber);
public record UpdateUserDto(string? Name, string? Email, string? PhoneNumber);
public record UserSummaryDto(int Id, string Name);
public record UserDetailDto(int Id, string Name, string Email, string? PhoneNumber, DateTime CreatedAt);
This is explicit and readable. The downside: every time User gains a new field, you revisit each DTO. Renaming a field means a rename across four files. The entity and its DTOs drift apart silently — the compiler won’t catch a DTO that’s missing a new required field if the property just didn’t exist yet.
For collections, you use IReadOnlyList<T>, IReadOnlyDictionary<K,V>, or ImmutableList<T>. For nominal typing — making UserId distinct from OrderId even though both are int — you use wrapper types or value objects. For state machines, you use class hierarchies with a common base and pattern matching (switch (state) { case SuccessState s: ... }).
TypeScript gives you different tools for the same problems. Some are strictly better. Some are more powerful but more dangerous. None of them require creating a new class.
The TypeScript Way
Partial<T> — PATCH Operations Without a Separate UpdateDto
Partial<T> takes a type and makes every property optional. For PATCH endpoints, this is the direct replacement for a hand-maintained UpdateUserDto:
// TypeScript — derive the update shape from the base type
interface User {
id: number;
name: string;
email: string;
phoneNumber: string | null;
createdAt: Date;
}
// Partial<User> is equivalent to:
// { id?: number; name?: string; email?: string; phoneNumber?: string | null; createdAt?: Date }
type UpdateUserDto = Partial<Omit<User, 'id' | 'createdAt'>>;
// NestJS controller — the body is typed, validated at runtime separately via Zod
@Patch(':id')
async updateUser(
@Param('id') id: number,
@Body() dto: UpdateUserDto,
): Promise<User> {
return this.userService.update(id, dto);
}
// C# equivalent — manual, must be maintained separately from User
public record UpdateUserDto(
string? Name,
string? Email,
string? PhoneNumber
);
The TypeScript version stays synchronized with User automatically. Add avatarUrl: string to User and UpdateUserDto gets it for free. In C#, you’d add it to UpdateUserDto manually or miss it.
The trade-off: Partial<T> makes ALL properties optional, including ones that might not make sense to update. Omit compensates for read-only fields like id and createdAt. For complex cases — some fields required, some optional — you combine utilities:
// Required name, optional everything else
type UpdateUserDto = Required<Pick<User, 'name'>> & Partial<Omit<User, 'id' | 'createdAt' | 'name'>>;
This is where TypeScript’s power becomes its own problem — complex composed types lose readability fast. If a type expression doesn’t fit in a single line of reasonable length, consider whether a named intermediate type would be clearer.
Pick<T, K> and Omit<T, K> — View Models Without Ceremony
Pick<T, K> creates a new type with only the specified keys. Omit<T, K> creates a type with the specified keys removed. Both replace the C# pattern of writing separate DTO classes for different views of the same entity.
// Instead of writing UserSummaryDto from scratch:
type UserSummary = Pick<User, 'id' | 'name'>;
// Instead of writing UserDetailDto that might miss new fields:
type UserDetail = Omit<User, 'createdAt'>;
// Combine with Partial for patch-safe view models:
type UserProfile = Pick<User, 'id' | 'name' | 'email'>;
// C# — must be kept in sync manually
public record UserSummary(int Id, string Name);
public record UserDetail(int Id, string Name, string Email, string? PhoneNumber);
When the TypeScript approach is better: Your entity has 15 fields and you need 6 different view shapes. Creating 6 hand-maintained DTOs in C# is noise. Six Pick and Omit expressions that derive from the source type stay correct automatically.
When the TypeScript approach is worse: Serialization and OpenAPI documentation. In C#, your DTO classes carry [JsonPropertyName], [Required], and Swagger annotations. TypeScript utility types produce anonymous structural types — they don’t carry NestJS Swagger decorators. If you need @ApiProperty() decorators on every field for @nestjs/swagger, you still need a class, not a Pick alias. This is the primary reason you’ll see NestJS projects using class-validator DTO classes instead of derived utility types.
The practical rule: use utility types in application logic and internal service interfaces. Use DTO classes decorated for validation and OpenAPI where the contract matters.
Record<K, V> — Typed Dictionaries
Record<K, V> creates an object type with keys of type K and values of type V. It’s the TypeScript equivalent of Dictionary<K, V> or IReadOnlyDictionary<K, V>.
// .NET: Dictionary<string, number>
// TypeScript: Record<string, number>
// Index a lookup table by role
type RolePermissions = Record<string, string[]>;
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
viewer: ['read'],
};
// K can be a union type — this constrains valid keys at compile time
type HttpStatusText = Record<200 | 201 | 400 | 401 | 404 | 500, string>;
const statusText: HttpStatusText = {
200: 'OK',
201: 'Created',
400: 'Bad Request',
401: 'Unauthorized',
404: 'Not Found',
500: 'Internal Server Error',
};
// TypeScript will error if you add a key not in the union, or miss one
// C# equivalent
Dictionary<int, string> statusText = new() {
{ 200, "OK" },
{ 201, "Created" },
// No compiler error if you forget a key
};
The union key variant of Record is strictly better than C#’s Dictionary for exhaustive mappings — the compiler verifies you’ve handled every case. Dictionary<int, string> has no equivalent constraint.
readonly Arrays and Objects — Immutability Without ImmutableList<T>
TypeScript’s readonly modifier and the Readonly<T> utility type create immutable views without allocating new data structures, unlike ImmutableList<T>.
// Readonly array — prevents mutation methods (push, pop, splice)
function processItems(items: readonly string[]): string[] {
// items.push('x'); // Error: Property 'push' does not exist on type 'readonly string[]'
return items.map(item => item.toUpperCase()); // Fine — returns new array
}
// Readonly object — prevents property reassignment
function displayUser(user: Readonly<User>): string {
// user.name = 'hacked'; // Error: Cannot assign to 'name' because it is a read-only property
return `${user.name} <${user.email}>`;
}
// ReadonlyArray<T> is the explicit form
const config: ReadonlyArray<string> = ['a', 'b', 'c'];
// C# equivalents — require separate interface or allocation
IReadOnlyList<string> items = new List<string> { "a", "b", "c" };
// ImmutableList<string> alloc = ImmutableList.Create("a", "b", "c");
The TypeScript readonly modifier is zero-cost at runtime — it only exists in the type system. ImmutableList<T> creates a new data structure. For function signatures where you want to communicate “I won’t mutate this,” readonly arrays are cleaner and cheaper.
Branded Types — Nominal Typing for Structural Types
This is the most important pattern in this article for engineers coming from C#.
TypeScript’s type system is structural: if two types have the same shape, they’re interchangeable. UserId (a string) and OrderId (a string) are the same type to TypeScript. Passing an OrderId where a UserId is expected compiles and runs silently.
// Without branding — this compiles. It should not.
type UserId = string;
type OrderId = string;
function getUser(id: UserId): Promise<User> { /* ... */ }
const orderId: OrderId = 'order-123';
getUser(orderId); // No error. You just passed an OrderId to a function expecting UserId.
Branded types add a phantom property that makes structurally identical types nominally distinct:
// Brand type utility
type Brand<T, B extends string> = T & { readonly __brand: B };
// Branded primitive types — same runtime value, distinct compile-time types
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type ProductId = Brand<string, 'ProductId'>;
// Constructor functions that create branded values
function toUserId(id: string): UserId {
return id as UserId;
}
function toOrderId(id: string): OrderId {
return id as OrderId;
}
// Now the compiler distinguishes them
function getUser(id: UserId): Promise<User> { /* ... */ }
const userId = toUserId('user-456');
const orderId = toOrderId('order-123');
getUser(userId); // Fine
getUser(orderId); // Error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'
// C# equivalent — a wrapper record or struct
public readonly record struct UserId(string Value);
public readonly record struct OrderId(string Value);
// Explicit, zero-ambiguity
public Task<User> GetUser(UserId id) { /* ... */ }
Judgment: The C# approach — a wrapper record — is cleaner and carries intent more clearly. The TypeScript branded type is a workaround for structural typing’s lack of nominal safety. Use branded types at system boundaries (API IDs, external identifiers) where mixing up types has real consequences. Don’t brand everything — the as UserId cast in the constructor is a trust boundary, and over-branding creates cast-heavy code that’s harder to read than it’s worth.
A common application: validating IDs at the Prisma or API layer and returning branded types so that downstream code can’t accidentally swap them.
The Result Pattern — Explicit Error Handling
TypeScript has no checked exceptions (neither does C# 8+, though some teams use OneOf or functional extensions). The Result<T, E> pattern makes error paths explicit in the type signature, similar to Rust’s Result or F#’s Result.
// Simple discriminated union Result type
type Result<T, E extends Error = Error> =
| { success: true; data: T }
| { success: false; error: E };
// Usage — the caller must handle both branches
async function createUser(dto: CreateUserDto): Promise<Result<User, ValidationError | DbError>> {
const validation = validateUser(dto);
if (!validation.ok) {
return { success: false, error: new ValidationError(validation.message) };
}
try {
const user = await db.user.create({ data: dto });
return { success: true, data: user };
} catch (err) {
return { success: false, error: new DbError('User creation failed', { cause: err }) };
}
}
// Caller is forced to handle the error path
const result = await createUser(dto);
if (!result.success) {
// result.error is typed as ValidationError | DbError here
logger.error(result.error.message);
throw result.error;
}
// result.data is User here — TypeScript narrowed the type
const user = result.data;
// C# equivalent — throw/catch or OneOf
// OneOf pattern (library: OneOf)
OneOf<User, ValidationError, DbError> CreateUser(CreateUserDto dto) { /* ... */ }
// Or the simpler throw approach
User CreateUser(CreateUserDto dto) {
// throws ValidationException or DbException
}
Judgment: The Result pattern is valuable at service layer boundaries where callers need to handle errors meaningfully rather than catching broadly. It’s poor for internal helper functions where you’d rather throw and let a global error handler deal with it. The NestJS convention — throwing HttpException subclasses in controllers and services, caught by exception filters — is simpler for API-facing code. Reserve Result<T, E> for business logic that must distinguish between multiple failure modes.
For teams that want a fuller implementation, the neverthrow library provides a Result type with monadic chaining (map, andThen, mapErr).
Discriminated Unions for State Machines
This is where TypeScript genuinely outperforms C#. Discriminated unions model state machines without class hierarchies, base classes, or pattern matching boilerplate.
// State machine for an order — each state carries its own data
type OrderState =
| { status: 'pending'; createdAt: Date }
| { status: 'processing'; startedAt: Date; processorId: string }
| { status: 'shipped'; shippedAt: Date; trackingNumber: string }
| { status: 'delivered'; deliveredAt: Date }
| { status: 'cancelled'; cancelledAt: Date; reason: string };
type Order = {
id: OrderId;
customerId: UserId;
items: OrderItem[];
state: OrderState;
};
// TypeScript narrows the type based on the discriminant property
function describeOrder(order: Order): string {
switch (order.state.status) {
case 'pending':
return `Order pending since ${order.state.createdAt.toISOString()}`;
case 'shipped':
// order.state.trackingNumber is available here — TypeScript knows the shape
return `Shipped: ${order.state.trackingNumber}`;
case 'cancelled':
return `Cancelled: ${order.state.reason}`;
default:
return order.state.status;
}
}
// Adding a new status 'refunded' will cause TypeScript to warn about
// non-exhaustive switch if you use a 'never' assertion:
function exhaustiveCheck(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
switch (order.state.status) {
// ... cases ...
default:
return exhaustiveCheck(order.state); // Error if any status is unhandled
}
// C# equivalent — abstract base class + derived types + pattern matching
public abstract record OrderState;
public record PendingState(DateTime CreatedAt) : OrderState;
public record ProcessingState(DateTime StartedAt, string ProcessorId) : OrderState;
public record ShippedState(DateTime ShippedAt, string TrackingNumber) : OrderState;
public record DeliveredState(DateTime DeliveredAt) : OrderState;
public record CancelledState(DateTime CancelledAt, string Reason) : OrderState;
string DescribeOrder(Order order) => order.State switch {
PendingState s => $"Order pending since {s.CreatedAt:O}",
ShippedState s => $"Shipped: {s.TrackingNumber}",
CancelledState s => $"Cancelled: {s.Reason}",
_ => "Unknown state"
};
The C# version requires four more files (the derived records), a base class, and the switch expression doesn’t give you a compiler error if you miss a case — it falls through to _. TypeScript’s discriminated union is more compact, and the exhaustiveCheck trick gives you compile-time exhaustiveness checking without C#’s default: throw idiom.
Judgment: Discriminated unions are a clear TypeScript win for state machines, event types in event-sourced systems, and API response shapes with multiple variants. The C# class hierarchy approach carries more ceremony for the same expressiveness.
Builder Pattern in TypeScript
In C#, builders create fluent APIs for constructing complex objects. TypeScript achieves this with method chaining, but you have two additional tools: the satisfies operator and type narrowing through state.
// Simple builder with type safety
class QueryBuilder<T extends Record<string, unknown>> {
private filters: Partial<T> = {};
private sortField?: keyof T;
private limitCount?: number;
where(field: keyof T, value: T[typeof field]): this {
this.filters[field] = value;
return this;
}
orderBy(field: keyof T): this {
this.sortField = field;
return this;
}
limit(n: number): this {
this.limitCount = n;
return this;
}
build(): { filters: Partial<T>; sort?: keyof T; limit?: number } {
return {
filters: this.filters,
sort: this.sortField,
limit: this.limitCount,
};
}
}
// Usage — type-safe against User's fields
const query = new QueryBuilder<User>()
.where('email', 'alice@example.com')
.orderBy('createdAt')
.limit(10)
.build();
// .where('nonexistent', 'value') would error — 'nonexistent' is not keyof User
The satisfies operator (TypeScript 4.9+) is useful for builder objects that need to conform to a type without losing their specific literal types:
const config = {
host: 'localhost',
port: 5432,
database: 'mydb',
} satisfies DbConfig;
// config.host is typed as 'localhost' (literal), not string
// But TypeScript still verifies the shape matches DbConfig
// C# builder — essentially the same pattern
var query = new QueryBuilder<User>()
.Where(u => u.Email == "alice@example.com")
.OrderBy(u => u.CreatedAt)
.Take(10)
.Build();
The TypeScript version is comparable to C# for simple builders. The difference is that TypeScript builders must return this to enable chaining, and the lack of extension methods means fluent APIs must be built into the class rather than added externally.
Key Differences
| Pattern | C# Approach | TypeScript Approach | TS Better When | TS Worse When |
|---|---|---|---|---|
| Partial DTO | Separate UpdateDto class | Partial<T> | Many view shapes of one entity | Need Swagger decorators on each field |
| View model | Separate SummaryDto class | Pick<T, K> / Omit<T, K> | Auto-sync with source type | Need validation attributes per field |
| Dictionary | Dictionary<K, V> | Record<K, V> | Exhaustive union keys | Runtime performance (same either way) |
| Immutability | IReadOnlyList<T>, ImmutableList<T> | readonly T[], Readonly<T> | Zero-cost (type-only) | Runtime enforcement — TS gives compile-time only |
| Nominal typing | Wrapper records / value objects | Branded types | No new class needed | C# wrappers carry serialization and EF mapping naturally |
| Error handling | Exceptions, OneOf, or FluentResults | Result<T, E> discriminated union | Explicit multi-error-type paths | Simple CRUD — exceptions are simpler |
| State machine | Abstract class hierarchy | Discriminated union | Compact, exhaustiveness check | External serialization needs class names |
| Builder | Fluent builder classes | Method-chaining class + satisfies | Same as C# for most cases | Extension method-style builders (no equivalent) |
Gotchas for .NET Engineers
1. Utility Types Are Compile-Time Only — No Runtime Validation
Partial<User>, Readonly<User>, Pick<User, 'id' | 'name'> — none of these do anything at runtime. They are erased by the TypeScript compiler. If you receive JSON from an API and assign it to a Partial<User>, TypeScript believes you but the actual runtime object could be anything.
// This compiles. At runtime, apiResponse could have any shape.
const dto = apiResponse as Partial<User>;
dto.name; // Could be undefined, could be a number, could not exist at all
This is different from C#, where a Partial<User> equivalent (an UpdateDto class) is a real class — the JSON deserializer validates shape, missing properties stay null or default, extra properties are ignored.
The fix is Zod for runtime validation. Every external boundary (API request bodies, external API responses) needs a Zod schema that validates at runtime. Don’t confuse TypeScript’s utility types with runtime safety — they’re orthogonal.
// Correct pattern
const updateUserSchema = z.object({
name: z.string().optional(),
email: z.string().email().optional(),
});
type UpdateUserDto = z.infer<typeof updateUserSchema>; // Derives the type from Zod
// Now updateUserSchema.parse(body) validates at runtime AND gives you the type
2. Readonly<T> Does Not Prevent Deep Mutation
Readonly<T> makes the direct properties of T read-only, but it is not deep. Nested objects within a Readonly<T> are still mutable.
type ReadonlyUser = Readonly<User>;
const user: ReadonlyUser = { id: 1, name: 'Alice', address: { city: 'Boston' } };
// This errors — direct property
// user.name = 'Bob'; // Error: Cannot assign to 'name' because it is a read-only property
// This does NOT error — nested property
user.address.city = 'NYC'; // Compiles fine. address itself is readonly, but city is not.
For deep immutability, use as const for static data, or a library like immer for mutable-style updates on truly immutable data. Do not rely on Readonly<T> for deep immutability the way you might rely on ImmutableList<T> in C#.
3. Discriminated Union Exhaustiveness Requires a Workaround
In C# 9+ switch expressions, the compiler tells you when a pattern match is non-exhaustive. TypeScript does not do this natively — a switch on a discriminated union’s status field with a missing case compiles without error and silently falls through.
The exhaustiveCheck(x: never) trick forces exhaustiveness:
function handleState(state: OrderState): string {
switch (state.status) {
case 'pending': return 'Pending';
case 'shipped': return 'Shipped';
// Missing 'processing', 'delivered', 'cancelled'
default:
// Without this, the missing cases silently return undefined
throw new Error(`Unhandled state: ${(state as any).status}`);
}
}
// With exhaustiveness check:
function handleStateExhaustive(state: OrderState): string {
switch (state.status) {
case 'pending': return 'Pending';
case 'shipped': return 'Shipped';
default:
// TypeScript errors here because state can still be 'processing' | 'delivered' | 'cancelled'
// — which are not assignable to 'never'
return exhaustiveCheck(state); // compile error until all cases are handled
}
}
function exhaustiveCheck(x: never): never {
throw new Error(`Exhaustive check failed: ${JSON.stringify(x)}`);
}
This is opt-in and requires discipline. Enable the @typescript-eslint/switch-exhaustiveness-check ESLint rule (see Article 2.7) to catch this automatically.
4. Complex Composed Types Hurt Readability
The ability to compose Partial, Pick, Omit, Required, and Readonly is powerful, but complex compositions become unreadable quickly:
// This is a type expression, not documentation
type T = Required<Pick<Omit<Partial<User>, 'id'>, 'name' | 'email'>> & Readonly<Pick<User, 'id'>>;
When a type expression becomes complex enough that you have to pause to parse it, extract named intermediate types. TypeScript’s type inference propagates through names — you lose nothing by naming intermediate types:
type UserUpdateFields = Omit<User, 'id' | 'createdAt'>;
type PartialUserUpdate = Partial<UserUpdateFields>;
type RequiredName = Required<Pick<PartialUserUpdate, 'name'>>;
type PatchUserDto = RequiredName & Omit<PartialUserUpdate, 'name'>;
In C# you’d just write the class. In TypeScript, composition gives you derived types — but name the intermediate steps.
5. Branded Types Require Cast at Construction — Don’t Skip Validation
The as UserId cast in a branded type constructor is a promise that the string is a valid UserId. If you cast without validating, you’ve defeated the purpose:
// Wrong — cast without validation
function toUserId(id: string): UserId {
return id as UserId; // You're just trusting the caller — no validation
}
// Better — validate before branding
function toUserId(raw: string): UserId {
if (!raw.startsWith('usr_') || raw.length < 10) {
throw new ValidationError(`Invalid UserId format: ${raw}`);
}
return raw as UserId;
}
// Best for API boundaries — use Zod with refinements
const UserIdSchema = z.string()
.startsWith('usr_')
.min(10)
.transform(val => val as UserId); // Brand after validation
The brand is only as trustworthy as the constructor. At API and database read points, validate the raw string against your format rules before casting. For identifiers coming from Prisma queries (which you trust), direct casting is fine — you know the database gives you valid IDs.
Hands-On Exercise
This exercise builds a typed state machine for a support ticket system using discriminated unions, branded types, and Result.
Create src/tickets/ticket.types.ts:
// 1. Brand the ticket ID
type Brand<T, B extends string> = T & { readonly __brand: B };
export type TicketId = Brand<string, 'TicketId'>;
export type AgentId = Brand<string, 'AgentId'>;
export function toTicketId(id: string): TicketId {
return id as TicketId;
}
// 2. Discriminated union state machine
export type TicketState =
| { status: 'open'; createdAt: Date }
| { status: 'assigned'; createdAt: Date; assignedTo: AgentId; assignedAt: Date }
| { status: 'in_progress'; assignedTo: AgentId; startedAt: Date }
| { status: 'resolved'; resolvedAt: Date; resolution: string }
| { status: 'closed'; closedAt: Date };
export interface Ticket {
id: TicketId;
title: string;
description: string;
state: TicketState;
}
// 3. Result type for transitions
type TransitionError = { code: 'INVALID_TRANSITION'; from: string; to: string };
type Result<T, E> = { success: true; data: T } | { success: false; error: E };
// 4. Exhaustiveness check helper
function exhaustiveCheck(x: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(x)}`);
}
// 5. State transition function — only valid transitions allowed
export function assignTicket(
ticket: Ticket,
agentId: AgentId,
): Result<Ticket, TransitionError> {
if (ticket.state.status !== 'open') {
return {
success: false,
error: {
code: 'INVALID_TRANSITION',
from: ticket.state.status,
to: 'assigned',
},
};
}
return {
success: true,
data: {
...ticket,
state: {
status: 'assigned',
createdAt: ticket.state.createdAt,
assignedTo: agentId,
assignedAt: new Date(),
},
},
};
}
// 6. Exhaustive display function — compiler catches missing cases
export function describeTicket(ticket: Ticket): string {
const { state } = ticket;
switch (state.status) {
case 'open':
return `Open since ${state.createdAt.toISOString()}`;
case 'assigned':
return `Assigned to ${state.assignedTo} at ${state.assignedAt.toISOString()}`;
case 'in_progress':
return `In progress by ${state.assignedTo}`;
case 'resolved':
return `Resolved: ${state.resolution}`;
case 'closed':
return `Closed at ${state.closedAt.toISOString()}`;
default:
return exhaustiveCheck(state); // Compile error if a status is unhandled
}
}
// 7. Utility type derivations for API layer
export type CreateTicketDto = Pick<Ticket, 'title' | 'description'>;
export type TicketSummary = Pick<Ticket, 'id' | 'title'> & { status: TicketState['status'] };
After writing this, try:
- Adding a new
'reopened'status toTicketStateand observe where the compiler flags the missing case - Passing an
AgentIdwhere aTicketIdis expected and verify the error - Calling
assignTicketon anin_progressticket and handling theResultproperly
Quick Reference
| Need | TypeScript | C# Equivalent |
|---|---|---|
| Optional all fields | Partial<T> | New DTO with nullable properties |
| Select some fields | Pick<T, 'a' | 'b'> | New DTO with selected properties |
| Exclude some fields | Omit<T, 'id' | 'createdAt'> | New DTO without those properties |
| Typed dictionary | Record<K, V> | Dictionary<K, V> |
| Readonly type | Readonly<T> | IReadOnlyList<T> / IReadOnly* |
| Readonly array | readonly T[] or ReadonlyArray<T> | IReadOnlyList<T> |
| Make all required | Required<T> | No direct equivalent |
| Nominal ID typing | Branded type: T & { __brand: B } | Wrapper record / value object |
| Exhaustive state | Discriminated union + exhaustiveCheck | Abstract class + switch expression |
| Explicit errors | Result<T, E> discriminated union | OneOf / FluentResults / exceptions |
| Builder pattern | Class with this return methods | Fluent builder class |
| Validated type | Zod schema + z.infer<typeof schema> | DTO class + Data Annotations |
| Runtime type check | instanceof (classes) / in operator / Zod | is T pattern matching |
Common Compositions
// Partial update, excluding system fields
type PatchDto<T> = Partial<Omit<T, 'id' | 'createdAt' | 'updatedAt'>>;
// Required subset
type RequiredFields<T, K extends keyof T> = Required<Pick<T, K>> & Partial<Omit<T, K>>;
// Deep readonly (naive — does not handle arrays inside objects)
type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> };
// Extract discriminant values from a union
type OrderStatus = OrderState['status']; // 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled'
Further Reading
- TypeScript Handbook — Utility Types — Official reference for every built-in utility type with examples
- TypeScript Handbook — Narrowing — How TypeScript narrows discriminated unions and the exhaustiveness check pattern
- neverthrow — A production-quality
Resulttype with monadic operations, if the DIY version above feels too minimal - TypeScript Deep Dive — Discriminated Unions — Deeper treatment of the pattern with real-world examples