# 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.

- Published: 2023-10-01
- Category: Languages
- Tags: TypeScript
- Reading time: 3 min read
- Source: https://www.muhammetsafak.com.tr/en/blog/type-level-programming-in-typescript-utility-types/
- Language: en-US
- Author: Muhammet Şafak

---
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](/en/blog/generics-and-type-inference-in-typescript/) 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 — the same instinct behind [end-to-end type safety](/en/blog/end-to-end-type-safety-with-typescript-from-api-to-ui/). 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>`.

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
// 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:

```typescript
// 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.
