Many people use TypeScript without ever writing a generic. The code compiles, works, and seems sufficient. When you need a function that works with any data type while maintaining type safety, generics stop being theory and become necessity.
Basic generics
A generic is a type parameter. Just like a function accepts value parameters, a generic accepts type parameters. Write once, type for any type.
// Without generic: need a function for each type
function getFirstString(arr: string[]): string | undefined { return arr[0] }
function getFirstNumber(arr: number[]): number | undefined { return arr[0] }
// With generic: one function for all
function getFirst<T>(arr: T[]): T | undefined {
return arr[0]
}
const a = getFirst(['a', 'b', 'c']) // type: string | undefined
const b = getFirst([1, 2, 3]) // type: number | undefined
TypeScript infers the generic type automatically. When you pass a string array, T becomes string. No need to declare it.
Constraints
Sometimes the generic needs a guarantee. Constraints solve this with extends:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const user = { name: 'Marc', age: 30 }
getProperty(user, 'name') // ok: returns string
getProperty(user, 'email') // error: 'email' doesn't exist on user
keyof T returns a union of T's property names. K extends keyof T ensures the passed key exists on the object. The error happens at compile time, not runtime.
Conditional types
Conditional types use the syntax T extends U ? X : Y. The resulting type depends on a condition. Think ternary, but for types.
type IsString<T> = T extends string ? true : false
type A = IsString<'hello'> // true
type B = IsString<42> // false
Real use case: deriving types based on inputs. An API that returns different types depending on the parameter.
type ApiResponse<T extends 'user' | 'product'> =
T extends 'user' ? { id: string; name: string; email: string }
: T extends 'product' ? { id: string; title: string; price: number }
: never
type UserResponse = ApiResponse<'user'> // { id: string; name: string; email: string }
type ProductResponse = ApiResponse<'product'> // { id: string; title: string; price: number }
Mapped types
Mapped types iterate over a type's properties and produce a new type. The syntax is { [K in Keys]: NewType }.
// Makes all properties optional
type MyPartial<T> = { [K in keyof T]?: T[K] }
// Makes all properties readonly
type MyReadonly<T> = { readonly [K in keyof T]: T[K] }
// Makes specific properties required
type WithRequired<T, K extends keyof T> =
T & Required<Pick<T, K>>
type User = { name?: string; email?: string; id?: string }
type UserWithId = WithRequired<User, 'id'>
// result: { name?: string; email?: string; id: string }
TypeScript utility types
TypeScript ships with built-in utility types. Knowing the most common ones avoids reinventing the wheel:
type User = { id: string; name: string; email: string; role: 'admin' | 'user' }
// Pick: selects properties
type UserBasic = Pick<User, 'id' | 'name'>
// Omit: removes properties
type CreateUser = Omit<User, 'id'>
// Record: creates type with typed keys and values
type Roles = Record<string, { permissions: string[] }>
// Extract: extracts types from a union
type AdminRole = Extract<User['role'], 'admin'>
// Exclude: removes types from a union
type NonAdmin = Exclude<User['role'], 'admin'> // 'user'
Real pattern: typing a function that accepts callbacks
function mapWithFallback<TInput, TOutput>(
items: TInput[],
transform: (item: TInput) => TOutput,
fallback: TOutput
): TOutput[] {
return items.map(item => {
try {
return transform(item)
} catch {
return fallback
}
})
}
// TypeScript infers TInput = string, TOutput = number
const result = mapWithFallback(
['1', '2', 'abc'],
s => parseInt(s),
0
) // type: number[]
When to stop overcomplicating
Generics and conditional types are powerful, but they have a readability cost. If you're writing a type nobody on the team can read in 10 seconds, simplify. Type safety matters, but maintainability matters more.
Use generics when you need type flexibility. Use conditional types when you have genuine type logic. Use mapped types when you need to transform structures. And when none of that applies, a documented, localized any is better than a genius type nobody understands.
Enjoyed this content?
I build web products and AI solutions the right way — solid architecture, maintainable code, and real delivery.
Let's talk