Understanding TypeScript Type Inference with Column Formatters

Understanding TypeScript Type Inference with Column Formatters

Β·

3 min read

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

  1. 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 }
}
  1. 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 }
  1. When you specify key: 'age', TypeScript knows that formatter must take a number

  2. When you specify key: 'name', TypeScript knows that formatter must take a string

  3. And 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!

Β