Skip to content
Muhammet Şafak
tr
Languages 5 min read

Type-level programming in TypeScript: utility types

TypeScript's built-in utility types and type transformation mechanisms: using the type system as a first-class tool.


When you first learn TypeScript, types look like simple labels: this variable is a string, this function returns a number. But TypeScript’s type system is capable of far more than that. You can perform computations on types, derive new types from existing ones, and write conditional type transformations.

Utility types are the ready-made constructs that ship with TypeScript’s standard library and encapsulate these transformation operations. Knowing them lets you work with the type system without falling into unnecessary repetition.

Partial, Required, Readonly

Three fundamental transformations: Partial<T>, Required<T>, Readonly<T>.

interface User {
    id: number
    name: string
    email: string
    bio?: string
}

// All fields become optional
type UserUpdate = Partial<User>
// { id?: number; name?: string; email?: string; bio?: string }

// All fields, including optional ones, become required
type UserComplete = Required<User>
// { id: number; name: string; email: string; bio: string }

// No field can be mutated
type ImmutableUser = Readonly<User>

Partial is commonly used in update (PATCH) requests or with partial form data. When building a payload type to send to an API, deriving it from an existing type is more reliable than defining it from scratch.

The practical value here is this: when you add a new field to the User interface, UserUpdate and UserComplete update automatically. If you maintain two manually derived types, there is always a risk of updating one and forgetting the other. Derived types eliminate that risk.

Pick and Omit

Selecting or excluding specific fields from a type:

interface Article {
    id: number
    title: string
    body: string
    authorId: number
    publishedAt: Date | null
    createdAt: Date
}

// Only the selected fields
type ArticlePreview = Pick<Article, 'id' | 'title' | 'publishedAt'>

// Everything except the specified fields
type ArticleInput = Omit<Article, 'id' | 'createdAt'>

These two are frequently useful at the API layer. Rather than returning a database model directly to the client, you can express which fields are visible at the type level as well.

I have developed a practical rule for choosing between Pick and Omit: if the number of fields you want to keep is fewer than the number you want to exclude, use Pick; otherwise, use Omit. This makes the code more readable. Using Omit to take nine out of ten fields is far clearer in intent than reaching for Pick.

Record

For key-value mappings:

type HttpStatus = 200 | 201 | 400 | 401 | 404 | 500

type StatusMessages = Record<HttpStatus, string>

const messages: StatusMessages = {
    200: 'OK',
    201: 'Created',
    400: 'Bad Request',
    401: 'Unauthorized',
    404: 'Not Found',
    500: 'Internal Server Error',
}

Record<K, V> is an object type where keys are of type K and values are of type V. It is used to safely model dynamic key sets.

Where Record really makes a difference is when you define the key set with a union type. In the example above, if you forget the 404 key in the messages object, TypeScript will warn you immediately. A plain { [key: number]: string } definition gives you no such check.

Conditional types: using infer

The mechanism underlying utility types is conditional types. The T extends U ? X : Y syntax is an if-else at the type level.

The infer keyword is used inside conditional types to capture another type:

// Extract the return type of a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never

async function fetchUser(id: number): Promise<User> {
    // ...
}

type Result = Awaited<ReturnType<typeof fetchUser>>
// Result = User

Awaited<T> is also a built-in utility type; it unwraps the type wrapped by a Promise<T>. It comes in handy frequently when working with async functions.

Transformations with mapped types

Mapped types let you apply a transformation to every field of a type. Most utility types are built on top of this mechanism:

// Approximate implementation of Partial
type MyPartial<T> = {
    [K in keyof T]?: T[K]
}

// Convert all fields to string
type Stringify<T> = {
    [K in keyof T]: string
}

type StringifiedUser = Stringify<User>
// { id: string; name: string; email: string; bio: string }

keyof T gives you the union of a type’s keys; T[K] gives you the value type for that key.

One area where mapped types shine is form state types. Instead of writing a standard interface to pair each field of a form model with a “value + error message” tuple, you can derive it with a mapped type. When a field is added to the interface, the form type updates automatically too.

When is all this actually necessary

Type-level programming is a powerful tool, but it does not need to be used everywhere. It genuinely adds value in these situations:

With large, interrelated sets of types. When a field change causes derived types to update automatically, the risk of manual synchronization errors disappears.

When writing a library or a shared layer. Presenting users with a flexible yet safe API requires type transformations.

When keeping the runtime and the type layer in sync becomes a struggle. Deriving types from Zod or io-ts schemas, for example, lets you work with a single source of truth instead of writing both separately.

Application code does not always need this level of complexity. Sometimes simple interface definitions are sufficient; type computations do not add value everywhere. I gauge the balance by asking whether someone else reading the code can easily understand it.

Tags: #TypeScript
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind