The table had its own type system because the pipeline didn’t give it one.

ColumnDef<R> — with getValue: (row: R) => unknown, render: (value, row) => TemplateResult, compare, sortable, filterable, width, align — existed as a bundled contract that did three jobs at once. Data extraction (what is the cell value?), cell rendering (how do you display it?), and presentation config (how wide, is it sortable?). It existed because DataReceiver.dataSet was typed unknown. The pipeline handled TypedDataSet internally — DataSourceController stored it, the append/replace/remove handlers constructed it — but the interface-level declaration said unknown. Everything downstream inherited the loss.

Fix the contract to TypedDataSet | undefined and the entire parallel type system becomes redundant. The data extraction concern moves to TypedRow.cell(columnId) — cells are already projected, with type discriminants (NUMBER, TEXT, DATE, LABEL, NULL) that the table can dispatch on for default formatting. The rendering concern becomes columnRenderers: ReadonlyMap<ColumnId, ColumnRenderer> — a map from column ID to a function that receives the typed cell, the full row, and the column metadata. The presentation concern becomes TableColumnConfig — label overrides, width, sortability, alignment, custom comparators.

Three independently-varying concerns that ColumnDef had bundled into one. Data schema is set by the pipeline. Rendering is set by the consumer. Presentation is set by the layout. They change for different reasons and at different times.

The package rename from pages-data-table to pages-table looks cosmetic but isn’t. The activation system creates elements via pages-${component.type} — for type: "table", it creates <pages-table>. The old PagesTable in pages-viz was already removed. Nothing was registered for that tag name. document.createElement("pages-table") produced a dead element. The rename makes the Lit component register as pages-table, which is the first time activation has had a working table element to resolve to.

The sort rewrite is worth noting for what it deletes. The old comparator took ColumnDef and extracted values via col.getValue(row) — raw unknown values compared by dispatching on the column’s string type ('text', 'number', 'date'). The new comparator takes TypedRow objects directly. It calls row.cell(columnId), gets back a discriminated CellValue, and dispatches on cell.type — an enum, not a string. NULL handling falls out naturally: if (cellA.type === 'NULL') return 1 is the entire nulls-last implementation.

For in-memory domain data that doesn’t come through the pipeline, fromRows<R>() absorbs what getValue used to do — but at construction time, not render time. You call fromRows(capabilities, [{ id: columnId("tag"), type: TEXT, getValue: c => c.tag }]) and get back a TypedDataSet with proper TypedRow instances. Domain-typed extractors run once. The table receives the same uniform input regardless of whether data came from a REST endpoint, a WebSocket push, or an in-memory array.

The three-concern split has a quiet consequence for the blocks-ui consumer migration that follows. Every component that currently builds a ColumnDef[] array and passes raw objects as rows will instead declare its columns in DataSourceControllerOptions, let the pipeline produce TypedDataSet, and pass columnRenderers only for cells that need custom rendering. The common case — column name as header, cell value formatted by type — needs zero configuration.


<
Previous Post
The Table Came Home
>
Next Post
Three Models, One Vocabulary