The Problem
Imagine you're building a data table component. You have data that looks like this:
type User = {
age: number;
name: string;
isActive: boolean;
}
For each column, you want to create a formatter function that handles the data for that column. The challenge is to have TypeScript automatically check that each formatter gets the right type of data based on the key.
What We Want
// We want to be able to write this:
const ageFormatter = {
key: 'age',
formatter: (value) => `${value} years old` // TypeScript should know value is number
};
const nameFormatter = {
key: 'name',
formatter: (value) => value.toUpperCase() // TypeScript should know value is string
};
First Attempt (Doesn't Work)
type ColumnFormatter<T> = {
key: keyof T;
formatter: (value: T[keyof T]) => void;
}
// Problem: value could be number | string | boolean
const badFormatter: ColumnFormatter<User> = {
key: 'age',
formatter: (value) => {} // value is number | string | boolean π’
};
Second Attempt (Works but Clunky)
Works because youβre now narrowing down based on the actual key with a second generic.
type ColumnFormatter<T, K extends keyof T> = {
key: K;
formatter: (value: T[K]) => void;
}
// Problem: Have to specify 'age' twice
const betterFormatter: ColumnFormatter<User, 'age'> = {
key: 'age',
formatter: (value) => {} // value is number β
but too verbose
};
Final Solution
// Same user type as above!
type User = {
age: number;
name: string;
isActive: boolean;
}
type ColumnFormatter<T> = {
[K in keyof T]-?: { key: K; formatter: (value: T[K]) => void }
}[keyof T];
// Perfect! TypeScript infers everything correctly
const formatter: ColumnFormatter<User> = {
key: 'age',
formatter: (value) => {} // value is number β
};
How It Works
- First, the mapped type creates this structure:
{
age: { key: 'age'; formatter: (value: number) => void }
name: { key: 'name'; formatter: (value: string) => void }
isActive: { key: 'isActive'; formatter: (value: boolean) => void }
}
- Then
[keyof T]
converts it to a union type. This way, it can only be one of the objects. And based on the specific key, TypeScript knows the value.
| { key: 'age'; formatter: (value: number) => void }
| { key: 'name'; formatter: (value: string) => void }
| { key: 'isActive'; formatter: (value: boolean) => void }
When you specify
key: 'age'
, TypeScript knows thatformatter
must take a numberWhen you specify
key: 'name'
, TypeScript knows thatformatter
must take a stringAnd so on...
Real World Usage
const formatters: ColumnFormatter<User>[] = [
{
key: 'age',
formatter: (value) => `${value + 1} years old` // value is number β
},
{
key: 'name',
formatter: (value) => value.toUpperCase() // value is string β
},
{
key: 'isActive',
formatter: (value) => value ? 'β
' : 'β' // value is boolean β
}
];
The great thing about this solution is that TypeScript automatically ensures type safety without needing extra type parameters. Each formatter function gets the exact type it needs based on the key you choose!