Published on

TypeScript's Type System Is More Powerful Than You Think

Authors

Most TypeScript code I see in the wild uses the type system as a slightly smarter JSDoc, annotating function arguments and return types, maybe a few interfaces. That's fine, but the type system is capable of a lot more. These are the features I reach for when I want types that actually catch real bugs.

Conditional Types

Conditional types let you express "if T extends U, then X, else Y" at the type level:

type IsString<T> = T extends string ? true : false

type A = IsString<string> // true
type B = IsString<number> // false

This becomes genuinely useful when you combine it with infer to extract parts of a type:

type UnpackPromise<T> = T extends Promise<infer Inner> ? Inner : T

type A = UnpackPromise<Promise<string>> // string
type B = UnpackPromise<number>          // number

infer lets TypeScript "capture" the type in a position you specify. The example above says: if T is a Promise, give me whatever it wraps; otherwise, give me T itself.

A practical use case, getting the return type of an async function:

async function fetchUser(id: number) {
  return { id, name: 'Alex', role: 'admin' }
}

type User = UnpackPromise<ReturnType<typeof fetchUser>>
// { id: number; name: string; role: string }

Mapped Types

Mapped types let you transform every property in an object type programmatically:

type Readonly<T> = {
  readonly [K in keyof T]: T[K]
}

type Optional<T> = {
  [K in keyof T]?: T[K]
}

These are so common TypeScript ships them as utilities (Readonly<T>, Partial<T>, Required<T>, etc.), but understanding that they're just mapped types lets you write your own.

Here's a pattern I use often, making specific keys required while leaving the rest optional:

type RequireFields<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>

type UserForm = {
  name?: string
  email?: string
  role?: string
}

type ValidUserForm = RequireFields<UserForm, 'name' | 'email'>
// { name: string; email: string; role?: string }

You can also remap keys with as:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

type Config = { host: string; port: number }
type ConfigGetters = Getters<Config>
// { getHost: () => string; getPort: () => number }

Template Literal Types

Template literal types let you construct string types the same way you'd construct template literal strings:

type EventName<T extends string> = `on${Capitalize<T>}`

type ClickEvent = EventName<'click'>  // 'onClick'
type ChangeEvent = EventName<'change'> // 'onChange'

This is excellent for typed event systems or generating consistent API key patterns:

type HttpMethod = 'get' | 'post' | 'put' | 'delete'
type Endpoint = '/users' | '/orders' | '/products'

type Route = `${Uppercase<HttpMethod>} ${Endpoint}`
// 'GET /users' | 'GET /orders' | 'GET /products'
// | 'POST /users' | 'POST /orders' ... etc

Discriminated Unions Over Optional Fields

This isn't a fancy feature, it's a design pattern. When you have types that represent different states, reach for discriminated unions instead of optional fields:

// Don't do this, every field is optional and nothing is guaranteed
type ApiResponse = {
  data?: User
  error?: string
  loading?: boolean
}

// Do this, each state is explicit and exhaustive
type ApiResponse =
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: User }

Now TypeScript will narrow correctly in your switch statements:

function render(response: ApiResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />
    case 'error':
      return <Error message={response.error} /> // response.error is string here
    case 'success':
      return <UserCard user={response.data} />   // response.data is User here
  }
}

If you add a new status to the union, TypeScript will tell you everywhere you haven't handled it.

The satisfies Operator

Added in TypeScript 4.9, satisfies lets you validate that a value matches a type without widening it:

type Color = 'red' | 'green' | 'blue'
type Palette = Record<string, Color | [number, number, number]>

// Without satisfies, TypeScript widens the value types
const palette: Palette = {
  red: [255, 0, 0],
  green: 'green',
}
// palette.red is Color | [number, number, number], tuple methods are gone

// With satisfies, types stay narrow
const palette = {
  red: [255, 0, 0],
  green: 'green',
} satisfies Palette
// palette.red is [number, number, number], tuple methods are available
// palette.green is 'green', string literal preserved

I use satisfies whenever I want both the validation guarantee of a type annotation and the precision of type inference.

Putting It Together

The common thread across all of these is encoding more information in types so the compiler can catch more bugs. Every optional field you collapse into a discriminated union is a cannot read property of undefined that you never write a test for. Every conditional type you write is a runtime branch you've pushed into compile time.

TypeScript's type system is Turing complete, you can express almost any constraint you'd want. The skill is knowing which constraints are worth expressing and which are over-engineering. Start with discriminated unions (almost always worth it), reach for mapped and conditional types when you're writing reusable utilities, and use satisfies whenever you catch yourself fighting with widening.