Skip to content

Infer the types of column meta and table meta - #202

Merged
NullVoxPopuli merged 5 commits into
universal-ember:mainfrom
NullVoxPopuli-ai-agent:nvp/inferred-table-types
Sep 23, 2026
Merged

NullVoxPopuli merged 5 commits into
universal-ember:mainfrom
NullVoxPopuli-ai-agent:nvp/inferred-table-types

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Sep 22, 2026 •

Copy link
Copy Markdown

An alternative to #200 that uses inference: no types value, no tableTypes() helper, and no type arguments. A declared type can replace each inferred type.

const table = headlessTable(this, {
  columns: () => [
    { key: 'name', meta: { align: 'left' }, Cell: GroupedCell },
    { key: 'age', meta: { align: 'right' }, Cell: AgeCell },
  ],
  data: () => this.people,
  meta: { currency: 'EUR' },
});

table.columns[0].meta;        // { align?: 'left' | 'right' } | undefined
table.config.meta?.currency;  // string | undefined
table.rows[0].data;           // Person, inferred as before

// <column.Cell @row={{row}} @column={{column}} @groupBy={{@groupBy}} />
// Glint reports a missing or wrong @groupBy, taken from GroupedCell's signature.

What each need from #200 becomes:

#200 Here
columnMeta slot Inferred from each column's meta, then merged. ColumnConfig<Person, Meta>[] declares it instead. satisfies checks one column in place.
tableMeta slot Inferred from the config's meta, added to TableMeta.
cellArgs slot Inferred from the Cells: every arg besides @row and @column that any Cell takes. ColumnConfig<Person, ColumnMeta, TableMeta, CellArgs>[] declares it instead. Works with template-only components, class components, and ComponentLike.
Typed Cells A Cell asks for the metas it reads through CellContext<T, ColumnMeta, TableMeta>. Each Cell is checked against the meta of its own column. TypeScript reports a column or table whose meta does not match. @row.table and @column.table both have the table's types.
@options column.getOptionsForRow has the @options that the Cells ask for, as in johanrd#5.
Helpers keep the types columns.for, next, previous, before, after, orderedColumnsFor.
Old code keeps working Column<Person>, Table<Person>, ColumnConfig<Person>[] accept typed tables and columns, also with cell args. So the plugin helpers take them unchanged. No existing test changed.

A new docs page, "Typing meta and cells", covers what is inferred, how to check it, how to declare it, how to write Cells, and the limits. The TypeScript page links to it.

Limits

  • In value and options, column.meta is unknown. Typed with the column meta, it would be any: the callbacks take their type from the plain column list next to the mapped one (see below), where the column meta is any. The table meta is typed there.
  • TypeScript does not check that a column's options returns the @options its Cell asks for. The return type of options is only known after the Cell types are fixed.
  • An inline <template> Cell has no types for its args. This is the same on main.
  • A function inside a meta literal needs annotated parameters.

Implementation notes

  • headlessTable infers a tuple of the column metas through a homomorphic mapped type with a const type parameter. ColumnMetaOf merges the tuple into one object type.
  • The cell args come from a second type parameter, the column list as written. Another mapped tuple for the Cells made callback columns lose their meta when the table had a meta. Inferring the args from Glint's ComponentLike directly keeps only the Cell with the fewest args. CellArgsOf reads each Cell's named args (from the TemplateOnlyComponent signature, from Glint's [Invoke], or from args) and intersects them.
  • The mapped list lives only in HeadlessTableConfig, the parameter type of headlessTable, so that TableConfig and table.config stay a plain interface. Next to it is a plain readonly ColumnConfig<DataType, any, Meta, any>[]. TypeScript does not infer DataType through the mapped list, so without the plain list DataType would come from data only.
  • ColumnConfig has a type-only [rowType]?: T property, as in Add per-table types: column meta, table meta, cell args #200. Without it, a list typed ColumnConfig[] gives a row type that depends on the order TypeScript checks the program in. TS 5.6 to 6.0 failed on this in test-app.
  • Column and Table default their cell args to any, so a bare Column<Person> means a column with any Cell. A narrower default made every plugin helper reject a column whose Cell takes args. Tables from headlessTable always carry their inferred args.
  • row.table is typed through an intersection on the rows and in CellContext. Adding the type parameters to the Row class made Table invariant in its meta.
  • CellContext has out variance annotations. Without them, TypeScript measures CellContext as unrelated to its meta parameters, because Column, Table and CellContext refer to each other in cycles. A Cell that needs table meta would then pass on a table without it.

Needs TypeScript 5.4 (NoInfer). The oldest version in the CI matrix is 5.6.

Checked:

  • The type tests in table/src/-private/-type-tests/ (inferred-meta, cell-args, column-controls-cell) pass with TS 5.6, TS 6, and the TS 7 nightly.
  • test-app/tests/integration/cells-test.gts renders Cells with extra args and with @options, and checks the Glint errors for a missing or wrong arg.
  • test-app type-checks with TS 5.6, 6.0, and the TS 7 nightly. CI runs the full matrix.
  • pnpm lint: pass.
  • test-app tests: 189 of 189 pass.
  • docs-app production build: pass.

🤖 Generated with Claude Code

NullVoxPopuli and others added 3 commits September 22, 2026 15:18
headlessTable infers the type of each column's `meta` and of the
table's `meta` from the config, with no type arguments and no helper.

- `column.meta` is the merged type of all column metas. A list with a
  declared type (`ColumnConfig<Person, Meta>[]`) keeps that type.
- `table.config.meta` is `TableMeta` plus the inferred meta.
- A `Cell` can ask for the column meta and table meta it reads, through
  `CellContext<T, ColumnMeta, Meta>`. Extra data for cells goes through
  the table meta.
- `columns.for`, `next`, `previous`, `before`, `after` and
  `orderedColumnsFor` keep the meta types.

`value` and `options` see the table meta, but not the column meta: a
typed column meta there makes TypeScript fix the metas before it infers
them. The mapped column list lives only in `HeadlessTableConfig`,
because a mapped type in `TableConfig` makes every `Table` comparison
structural, and `Table<X>` would stop fitting `Table<unknown>`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Callbacks were the only place ColumnConfig used T. For a list typed
ColumnConfig[], the row type that headlessTable inferred then depended
on the order TypeScript checked the program in: TS 5.6 to 6.0 inferred
the data type in test-app, and Table<Row> did not fit Table<unknown>.
A type-only property that mentions T makes it unknown every time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both comments named causes that did not hold up when checked again:
typed callbacks would get `any` from the plain column list, and a
mapped type in TableConfig does not break Table comparisons.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@johanrd

johanrd commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

@NullVoxPopuli yes, this approach also seems to work well! Column meta and table meta are inferred from annotated column lists, and compared to #200, the types: tableTypes<…>() lines can be deleted.

One gap on cell args though. Some of our tables give a Cell args from the template, in addition to @row and @column. Some are per row, so table meta can't carry them:

<column.Cell @row={{row}} @column={{column}} @groupBy={{@groupBy}} @onUpdate={{this.updateFor row}} />

This type test fails on #202: the column config does not accept the Cell.

type GroupedCellArgs = CellContext<Person> & {
  groupBy: 'day' | 'week';
  onUpdate: (value: string) => void;
};
declare const GroupedCell: ComponentLike<GroupedCellArgs>;

const table = headlessTable({}, {
  columns: () => [{ key: 'name', Cell: GroupedCell }],
  data: () => people,
});

expectTypeOf(table.columns[0]!.Cell).toEqualTypeOf<
  ComponentLike<GroupedCellArgs> | undefined
>();

Maybe, a ColumnConfig type parameter for cell args that is not inferred?

A Cell can take args besides @Row and @column, passed where it is
rendered (<column.Cell @Row @column @groupby={{...}} />). headlessTable
reads them from the Cells of the column list, and column.Cell asks for
all of them. Cells whose args differ add up. A column list typed as
ColumnConfig<Row, ColumnMeta, TableMeta, CellArgs>[] replaces the
inferred args.

The args are read the way Glint reads them, so template-only
components, class components and ComponentLike all work.

A new docs page, "Typing meta and cells", covers what is inferred, how
to check it, how to declare it, how to write Cells, and the limits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@johanrd

johanrd commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

cfbb714 closes the cell args gap, thanks🎉 I then tried to migrate our app to this head, and met two blockers:

1. The plugin helpers reject a column whose Cell takes args. CellArgs sits in the args of Column.Cell, so it is contravariant, and Column<Person, unknown, unknown, {groupBy}> does not fit Column<DataType>

declare const GroupedCell: ComponentLike<CellContext<Person> & { groupBy: 'day' | 'week' }>;

const table = headlessTable({}, {
  columns: () => [{ key: 'name', Cell: GroupedCell }],
  data: () => people,
});

isVisible(table.columns[0]!); // error TS2345
sort(table.columns[0]!);      // error TS2345

See johanrd#4 for a possible fix.

2. getOptionsForRow still returns { defaultValue: string }. So the pattern the options doc comment describes cannot be rendered when the Cell declares a shape:

<column.Cell @row={{row}} @column={{column}} @options={{column.getOptionsForRow row}} />

See johanrd#5 for a possible fix.

@johanrd

johanrd commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

also johanrd#6

…@options

- Column and Table default their cell args to `any`, so `Column<Person>`
  and `Table<Person>` accept a column or table whose Cells take extra
  args. The plugin helpers (`sort`, `isVisible`, `hide`, ...) take them
  with no change to their signatures. Tables from headlessTable still
  carry their inferred args.
- `row.table` has the types of the table, like `column.table`, in the
  rows of a table and in `CellContext`.
- `getOptionsForRow` has the `@options` that the Cells ask for, as in
  johanrd#5.
- Tests: each column decides which Cell fits it through its own meta,
  the plugin helpers accept a column with cell args, and a Cell renders
  with `@options={{column.getOptionsForRow row}}`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
key: 'name',
meta: { align: 'left' },
// @ts-expect-error this column's meta has no `width`
Cell: WidthCell,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huzzah!

@johanrd

johanrd commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

great! now only missing CellArgsOf array branch (johanrd#6) similar to ColumnMetaOf

also some public exports could be added (johanrd#5)

@NullVoxPopuli
NullVoxPopuli merged commit bcbd5cc into universal-ember:main Sep 23, 2026
17 checks passed
@github-actions github-actions Bot mentioned this pull request Sep 23, 2026
johanrd added a commit to johanrd/table that referenced this pull request Sep 23, 2026
universal-ember#202 makes `CellArgs` default to `any` on `Column` and `Table`, so that a
column whose Cell asks for args fits a `Column<DataType>` parameter,
which the plugin helpers need.

It also stops checking the Cell of every column written by hand.
`Column<Person>['Cell']` is `ComponentLike<any>`: it takes any args and
requires none, not even `@row` and `@column`. On 4.0.0 it was
`ComponentLike<CellContext<T>>`.

Both hold if the Cell is read through a type whose `[Invoke]` is a
method. TypeScript compares the parameters of a method bivariantly, so
such a column fits a parameter that names no args, while the args are
still checked where the Cell is rendered. The config keeps
`ComponentLike`, so a Cell is still checked against its column's meta.

The default goes back to `unknown`. The plugin helpers are untouched.

Cowritten by Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants