- Published on
TypeScript's Type System Is More Powerful Than You Think
- Authors

- Name
- Alex Peng
- @aJinTonic
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.