Concepts worth knowing first
Let me unpack the words that keep coming up in the body.
- union — a list spelling out "this value is either A or B", like
'orderNo' | 'total'. Once you enumerate the allowed values, typos get caught at compile time. - compound component — a bundle of pieces you assemble yourself, like
Table.RootandTable.Body. Instead of endlessly adding boolean props for options, you pick and place only the pieces you need. - widening — what TypeScript does when it infers a type from a value and deliberately bumps it to something looser. It's a kindness meant for the case where you'll swap the value later, but it gets in the way when you want an exact list.
- variance — the rule that decides whether, when
Dogis a kind ofAnimal,Dog[]or a function takingDoginherits that relationship too.
What I worked on today
| Task | What I wanted | What I did | Result |
|---|---|---|---|
| Lock down the sort-key type | I wanted to stop column-name typos from reaching runtime | Derived a union from the column array + a const type parameter | Delete a column and every related call site fails to compile |
| Pass the row type through | I wanted to send a per-domain row type down into the parts | Created the context inside a generic function and captured it in a closure | The type stays alive all the way down, no as needed |
| Dig into variance | I needed a reason to pick one constraint over the other | Checked how strictly method shorthand vs. function properties are checked | Chose a structural constraint that doesn't lean on variance |
| Leave evidence behind | I wanted "blocked by types" to be verifiable | Pinned the uses that must not compile with a type test | If the protection disappears, the test goes red |
1. Pulling the sort key out of the column array
I was building a table component, and the value holding "which column are we sorting by right now" was just a string. Which means a typo like sort.columnId = 'ordreNo' gets no complaint from the compiler at all. You only find out sorting is broken once you run it.
But writing it out by hand creates a different problem.
const columns = [{ id: "orderNo" }, { id: "total" }];
type ColumnId = "orderNo" | "total"; // a hand-written copy
Now two places each own the same fact. Delete a column but leave the union in place, and the types pass while only the screen breaks.
So I decided to make the column array itself the source of truth and compute the union from it.
type ColumnIdOf<Cols extends readonly { id: string }[]> = Cols[number]["id"];
Cols[number] merges the types of every element in the array, and putting ["id"] on top distributes across each member, giving you the union of all the ids.
Except this alone doesn't work. This is the part that surprised me most today.
const cols = [{ id: "orderNo" }, { id: "total" }];
type Bad = (typeof cols)[number]["id"]; // string
Even though I declared it with const, object properties still widen. So the result is string. No error, the protection just quietly drops to zero. If I hadn't noticed this and had moved on thinking "blocked by types", I'd have shipped code that blocks nothing.
To actually block it, the function has to enforce it.
function defineColumns<const T extends readonly { readonly id: string }[]>(cols: T): T {
return cols;
}
You could also use as const at the call site, but then it's silently defeated the same way whenever a consumer forgets it. For a public API, letting the function enforce it is the right call.
I also learned that readonly absolutely has to be in the constraint. Since const inference produces a readonly tuple, compilation fails if the constraint is just an array. Thankfully this one fails loudly, so you catch it right away.
→ Deriving types from values — indexed access and const type parameters
2. Sending a per-domain row type down into the parts
Several domains had to reuse the table, but a single row looks different in each domain. When I tried to carry that type in a context, the syntax stopped me.
function f<T>(x: T) {} // ✅ functions can take type parameters
type Box<T> = { v: T }; // ✅ type aliases can too
const Ctx<T> = createContext<T>(null); // ❌ const declarations can't take type parameters
createContext(...) is a value, not a type. The moment you call it, the type is pinned to one thing.
I dropped two workarounds right away. Making it unknown and casting with as at every consumption point means baking the domain type into the parts, which kills the whole point of reuse. Copying the file per domain duplicates the entire logic.
The answer was to create the context inside a generic function, and let the components created alongside it grab that via closure.
export function createDataTable<Row>() {
type ContextValue = { columns: readonly ColumnDef<Row>[]; getRowId: (row: Row) => string };
const Ctx = createContext<ContextValue | null>(null);
const useTable = () => {
const c = useContext(Ctx);
if (!c) throw new Error("Table part must be used inside Table.Root");
return c;
};
function Body() {
const { columns, getRowId } = useTable(); // Row is still alive here, no as needed
/* ... */
}
return { Root, Head, Body }; // Ctx itself is not exported
}
One call equals one independent set of table pieces. Mix in a part from a different factory and it throws at runtime; pass columns from a different domain and it won't compile.
There's one trap I hit here. Calling the factory inside a component body doesn't error. The context wiring works fine too, because the Provider and the consumer came out of the same closure.
The real problem is that the components get a new function identity on every render. React sees that as a different component and unmounts and remounts it. The symptom isn't an error, it's "scroll position and input values keep vanishing", which makes the cause far harder to track down.
3. Agonizing over the constraint, ending up in variance
I went back and forth on whether the constraint should be readonly ColumnDef<never>[] or readonly { readonly id: string }[]. The first one works too, but the reason it works bothered me. It was "because the cell callback is judged contravariant".
That's where I properly looked at variance. The reason function parameters are contravariant is more intuitive than it sounds. A function that accepts something broader is safe to drop into a narrower slot.
const handleAnyAnimal = (x: Animal) => console.log(x.name);
const handleOnlyDog = (x: Dog) => x.bark();
const h1: (x: Dog) => void = handleAnyAnimal; // ✅ passing a dog is fine
const h2: (x: Animal) => void = handleOnlyDog; // ❌ if a cat shows up, there's no bark()
But then I found the decisive bit. Even with strictFunctionTypes on, method shorthand syntax is an exception.
interface WithMethod {
on(cb: (e: Dog) => void): void; // bivariant — loose
}
interface WithProperty {
on: (cb: (e: Dog) => void) => void; // contravariant — strict
}
Two declarations that read almost identically get checked with different strictness. Which means the moment someone rewrites cell as method shorthand, the very ground my constraint stood on collapses. The protection is hanging on a single character of syntax.
So I picked a constraint that only demands structure and doesn't lean on variance judgments. "A readonly array of readonly objects that have an id" is enough, and that doesn't wobble no matter which syntax you use.
→ TypeScript variance — covariance and contravariance
4. Making "blocked by types" verifiable
What happened in section 1 kept nagging at me: the protection can disappear without making a sound.
So I boiled the judgment down to one question.
Of the broken code you could have written before, what no longer compiles?
I decided not to add any type complexity that can't answer that question concretely. And when it can be answered, I leave that answer behind as a test.
// positive — a normal call infers without type arguments
const ok: SortKey = "total";
// negative — one line per misuse
// @ts-expect-error typo
const typo: SortKey = "ordreNo";
The key is how @ts-expect-error differs from @ts-ignore. If the error goes away, this line goes red as "unused". The disappearance of the protection is itself the failure.
I learned one more thing here. Writing the test isn't the end — you have to check whether that test is actually alive. I deliberately removed const from the const type parameter and watched whether the test went RED. If it doesn't go red, that test is guarding nothing.
I picked the cases at the boundaries
At first I set the bar for negative cases at "at least 3". But once they were written, all three turned out to poke at the same boundary — I'd written the same column-id typo three times. I hit the count, but I was only catching one thing.
So I took boundary value analysis (BVA) from runtime tests and moved it onto types as is. Just as "at least 2 characters" means you check 1, 2, and 3. Only a type's boundary isn't the size of a value, it's the extreme of the type lattice.
Three boundaries actually applied to the column type I built today.
// boundary 1. union member / non-member
const ok: SortKey = "total";
// @ts-expect-error a column that doesn't exist
const bad1: SortKey = "userId";
// boundary 2. literal / widened string ← the exact spot that quietly collapsed in section 1
// @ts-expect-error a value already widened to string
const bad2: SortKey = "total" as string;
// boundary 3. inference authority — is the const parameter really holding the literals
const cols = defineColumns([{ id: "orderNo" }, { id: "total" }]);
The optional, readonly, tuple-arity, and never/any/unknown axes don't apply to this API, so I didn't write any. Filling in cases for boundaries you don't even close is filling a count all over again.
And I learned one more thing. Some of BVA's four axes are ones types can't cover — time/ordering and side-effect counts. Today I only did type design, so I didn't touch those two axes at all; I wrote them down separately as the part that has to be handed to runtime tests.
→ Type-level boundary value analysis — picking type test cases with BVA
→ Type-level testing — the evidence the compiler leaves behind
One line for today
The claim that you blocked something with types is true only when you can concretely answer "what no longer compiles?" And that answer turns false without any warning the moment one piece of syntax goes missing. The three things I learned today — that derivation and const are a set, that calling a factory during render silently remounts, and that method shorthand loosens checking — all share one thing: the failures aren't loud.
Derived knowledge
- TypeScript variance — covariance and contravariance
- Deriving types from values — indexed access and const type parameters
- Generic context factory
- Type-level testing — the evidence the compiler leaves behind
- Type-level boundary value analysis — picking type test cases with BVA
- The State Modeling Ladder
- Types That Claim More Than Runtime Delivers
- Enforcing Exhaustiveness
- The Environment Behind a Type Contract