From aa68d71d24fc404b6493a23d22638c6127e5f76a Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:18:00 -0400 Subject: [PATCH 1/5] Infer the types of column meta and table meta 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[]`) 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`. 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` would stop fitting `Table`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../1-get-started/typescript-and-glint.gjs.md | 53 ++++ .../-type-tests/inferred-meta.test.ts | 233 ++++++++++++++++++ table/src/-private/column.ts | 18 +- table/src/-private/interfaces/column.ts | 36 ++- table/src/-private/interfaces/table.ts | 40 ++- table/src/-private/js-helper.ts | 16 +- table/src/-private/meta.ts | 39 +++ table/src/-private/table.ts | 27 +- table/src/index.ts | 2 + table/src/plugins/-private/base.ts | 38 ++- .../src/plugins/column-reordering/helpers.ts | 16 +- 11 files changed, 476 insertions(+), 42 deletions(-) create mode 100644 table/src/-private/-type-tests/inferred-meta.test.ts create mode 100644 table/src/-private/meta.ts diff --git a/docs-app/src/templates/1-get-started/typescript-and-glint.gjs.md b/docs-app/src/templates/1-get-started/typescript-and-glint.gjs.md index 667c4045..e4b47593 100644 --- a/docs-app/src/templates/1-get-started/typescript-and-glint.gjs.md +++ b/docs-app/src/templates/1-get-started/typescript-and-glint.gjs.md @@ -71,6 +71,59 @@ class Demo { } ``` +### Column and table meta + +A column's `meta` and the table's `meta` are for information that is not tied to a row, for example the alignment of a column. +Their types are inferred from the config, so there is nothing to declare. + +```ts +class Demo { + table = headlessTable(this, { + columns: () => [ + { key: "name", meta: { align: "left" } }, + { key: "age", meta: { align: "right" } }, + ], + data: () => this.people, + meta: { currency: "EUR" }, + }); +} + +// column.meta: { align?: 'left' | 'right' } | undefined +// table.config.meta.currency: string +``` + +To check each column against a shape, use `satisfies`, or give the list a type: + +```ts +interface Alignment { + align?: "left" | "right"; +} + +class Demo { + // checks this column, and keeps the inferred type + table = headlessTable(this, { + columns: () => [ + { key: "name", meta: { align: "left" } satisfies Alignment }, + ], + data: () => this.people, + }); + + // every column in the list has `Alignment` as its meta + columns: ColumnConfig[] = [ + /* ... */ + ]; +} +``` + +A `Cell` component can ask for the meta it reads. +TypeScript then reports a column or table whose meta does not match: + +```ts +const AlignedCell: TOC<{ + Args: CellContext; +}> = ; +``` + ## In Templates [Glint][docs-glint] can be a great choice to help ensure that your code is as bug-free as possible. diff --git a/table/src/-private/-type-tests/inferred-meta.test.ts b/table/src/-private/-type-tests/inferred-meta.test.ts new file mode 100644 index 00000000..da34983e --- /dev/null +++ b/table/src/-private/-type-tests/inferred-meta.test.ts @@ -0,0 +1,233 @@ +import { expectTypeOf } from 'expect-type'; + +import { headlessTable } from '../../index.ts'; +import { + ColumnVisibility, + isVisible, +} from '../../plugins/column-visibility/index.ts'; +import { DataSorting, sort } from '../../plugins/data-sorting/index.ts'; +import { columns, meta } from '../../plugins/index.ts'; + +import type { CellContext, Column, ColumnConfig, Table } from '../../index.ts'; +import type { ComponentLike } from '@glint/template'; + +interface Person { + name: string; + age: number; +} +declare const people: Person[]; + +///////////////////////////////////////////// +// Column meta and table meta are inferred from the config +const report = headlessTable( + {}, + { + columns: () => [ + { key: 'name', meta: { align: 'left' } }, + { key: 'age', meta: { align: 'right', exportWidth: 12 } }, + { key: 'plain' }, + ], + data: () => people, + meta: { + updateCell: (data: Person, key: string, value: unknown) => { + data[key as keyof Person] = value as never; + }, + }, + plugins: [ColumnVisibility, DataSorting], + }, +); + +// the row type is still inferred +expectTypeOf(report.rows[0]!.data).toEqualTypeOf(); + +// each column's meta merges into one type +expectTypeOf(report.columns[0]!.meta).toEqualTypeOf< + { align?: 'left' | 'right'; exportWidth?: 12 } | undefined +>(); + +// the table meta keeps the keys of TableMeta +expectTypeOf(report.config.meta!.updateCell) + .parameter(0) + .toEqualTypeOf(); +expectTypeOf(report.config.meta!.totalRowCount).toEqualTypeOf< + number | undefined +>(); +expectTypeOf(report.columns[0]!.table.config.meta!.updateCell).toBeFunction(); + +// helpers that return columns keep the type +expectTypeOf(columns.for(report)[0]!.meta?.align).toEqualTypeOf< + 'left' | 'right' | undefined +>(); +expectTypeOf(columns.next(report.columns[0]!)!.meta?.align).toEqualTypeOf< + 'left' | 'right' | undefined +>(); + +// plugin helpers take the typed table and column +expectTypeOf(isVisible(report.columns[0]!)).toEqualTypeOf(); +sort(report.columns[0]!); +meta.forColumn(report.columns[0]!, ColumnVisibility); + +// code that knows nothing about meta accepts the table and its columns +function takesAnyColumn(column: Column) { + return column.key; +} +function takesAnyTable(table: Table) { + return table.columns.length; +} +takesAnyColumn(report.columns[0]!); +takesAnyTable(report); + +// shared code can ask for the meta it needs +function exportWidthOf(column: Column) { + return column.meta?.exportWidth; +} +exportWidthOf(report.columns[0]!); + +///////////////////////////////////////////// +// Without meta, reading meta is an error +const plain = headlessTable( + {}, + { + columns: () => [{ key: 'name' }], + data: () => people, + }, +); + +expectTypeOf(plain.columns[0]!.meta).toEqualTypeOf(); +// @ts-expect-error nothing sets a column meta +expectTypeOf(plain.columns[0]!.meta?.align); + +///////////////////////////////////////////// +// Callbacks do not stop the inference, and see the row and the table meta +const withCallbacks = headlessTable( + {}, + { + columns: () => [ + { + key: 'age', + meta: { align: 'right' }, + value: ({ column, row }) => { + expectTypeOf(row.data).toEqualTypeOf(); + expectTypeOf(column.table.config.meta!.unit).toEqualTypeOf(); + + return row.data.age; + }, + options: ({ column }) => ({ + unit: column.table.config.meta!.unit, + }), + }, + ], + data: () => people, + meta: { unit: 'years' }, + }, +); + +expectTypeOf(withCallbacks.columns[0]!.meta?.align).toEqualTypeOf< + 'right' | undefined +>(); + +///////////////////////////////////////////// +// A declared meta type checks every column, and is kept as it is +interface ReportColumnMeta { + align?: 'left' | 'right'; + exportWidth?: number; +} + +const typedList: ColumnConfig[] = [ + { key: 'name', meta: { align: 'left' } }, +]; +const fromList = headlessTable( + {}, + { + columns: () => typedList, + data: () => people, + }, +); + +expectTypeOf(fromList.columns[0]!.meta).toEqualTypeOf< + ReportColumnMeta | undefined +>(); + +const badList: ColumnConfig[] = [ + // @ts-expect-error not one of the declared alignments + { key: 'name', meta: { align: 'middle' } }, +]; + +// `satisfies` checks a column in place, and the literal type is still inferred +const checked = headlessTable( + {}, + { + columns: () => [ + { key: 'name', meta: { align: 'left' } satisfies ReportColumnMeta }, + ], + data: () => people, + }, +); +expectTypeOf(checked.columns[0]!.meta?.align).toEqualTypeOf< + 'left' | undefined +>(); + +///////////////////////////////////////////// +// Extra args for cells come through the table meta +interface DateRangeMeta { + dateRange: [Date, Date]; +} + +declare const DateCell: ComponentLike< + CellContext +>; + +const ranged = headlessTable( + {}, + { + columns: () => [{ key: 'name', Cell: DateCell }], + data: () => people, + meta: { dateRange: [new Date(), new Date()] as [Date, Date] }, + }, +); + +expectTypeOf(ranged.columns[0]!.table.config.meta!.dateRange).toEqualTypeOf< + [Date, Date] +>(); + +// A Cell that reads column meta fits columns whose meta matches +declare const AlignedCell: ComponentLike< + CellContext +>; + +headlessTable( + {}, + { + columns: () => [ + { key: 'name', meta: { align: 'left' }, Cell: AlignedCell }, + { key: 'age', Cell: AlignedCell, meta: { align: 'right' } }, + ], + data: () => people, + }, +); + +headlessTable( + {}, + { + columns: () => [ + { + key: 'name', + meta: { align: 'middle' }, + // @ts-expect-error not one of the alignments the Cell handles + Cell: AlignedCell, + }, + ], + data: () => people, + }, +); + +headlessTable( + {}, + { + // @ts-expect-error this table's meta has no `dateRange` + columns: () => [{ key: 'name', Cell: DateCell }], + data: () => people, + }, +); + +void badList; diff --git a/table/src/-private/column.ts b/table/src/-private/column.ts index b28810c9..0c8a7a8f 100644 --- a/table/src/-private/column.ts +++ b/table/src/-private/column.ts @@ -12,8 +12,14 @@ const DEFAULT_OPTIONS = { [DEFAULT_VALUE_KEY]: DEFAULT_VALUE, }; -export class Column { - get Cell(): ComponentLike> | undefined { +/** + * `ColumnMeta` is the type of `meta`, and `Meta` the type of `table.config.meta`. + * + * `config` and `Cell` do not carry the column meta, + * so that a column fits wherever a column with a wider meta is expected. + */ +export class Column { + get Cell(): ComponentLike> | undefined { return this.config.Cell; } @@ -25,9 +31,13 @@ export class Column { return this.config.name; } + get meta(): ColumnMeta | undefined { + return this.config.meta as ColumnMeta | undefined; + } + constructor( - public table: Table, - public config: ColumnConfig, + public table: Table, + public config: ColumnConfig, ) {} @action diff --git a/table/src/-private/interfaces/column.ts b/table/src/-private/interfaces/column.ts index 7b893d0a..1968e60c 100644 --- a/table/src/-private/interfaces/column.ts +++ b/table/src/-private/interfaces/column.ts @@ -5,8 +5,14 @@ import type { ColumnOptionsFor, SignatureFrom } from './plugins'; import type { Constructor } from '../private-types'; import type { ComponentLike, ContentValue } from '@glint/template'; -export interface CellContext { - column: Column; +/** + * What `value`, `options`, and a `Cell` receive. + * + * `ColumnMeta` is the `meta` of the column, + * and `Meta` is the `meta` of the table config. + */ +export interface CellContext { + column: Column; row: Row; } @@ -21,7 +27,11 @@ export type CellOptions = { defaultValue?: string; } & Record; -export interface ColumnConfig { +export interface ColumnConfig< + T = unknown, + ColumnMeta = unknown, + Meta = unknown, +> { /** * the `key` is required for preferences storage, as well as * managing uniqueness of the columns in an easy-to-understand way. @@ -36,25 +46,39 @@ export interface ColumnConfig { /** * Optionally provide a function to determine the value of a row at this column + * + * `column.meta` is `unknown` here, and in `options`. + * If callbacks were typed with it, TypeScript would fix the column metas + * before it reads them, and a list where every column has a callback + * would lose its meta type. */ - value?: (context: CellContext) => ContentValue; + value?: (context: CellContext>) => ContentValue; /** * Recommended property to use for custom components for each cell per column. * Out-of-the-box, this property isn't used, but the provided type may be * a convenience for consumers of the headless table */ - Cell?: ComponentLike>; + Cell?: ComponentLike, NoInfer>>; /** * The name or title of the column, shown in the column heading / th */ name?: string; + /** + * Information about the column that is not tied to a row, + * for example the alignment of its cells. + * + * Read it back as `column.meta`. + * Its type is inferred from what the columns config provides. + */ + meta?: ColumnMeta; + /** * Bag of extra properties to pass to Cell via `@options`, if desired */ - options?: (context: CellContext) => CellOptions; + options?: (context: CellContext>) => CellOptions; /** * Each plugin may provide column options, and provides similar syntax to how diff --git a/table/src/-private/interfaces/table.ts b/table/src/-private/interfaces/table.ts index b22aa327..79a22d4a 100644 --- a/table/src/-private/interfaces/table.ts +++ b/table/src/-private/interfaces/table.ts @@ -9,13 +9,16 @@ export interface TableMeta { totalRowsSelectedCount?: number; } -export interface TableConfig { +/** + * `Meta` is the type of this config's own `meta`. + */ +export interface TableConfig { /** * Configuration describing how the table will crawl through `data` * and render it. Within this `columns` config, there will also be opportunities * to set the behavior of columns when rendered */ - columns: () => ColumnConfig[]; + columns: () => ColumnConfig[]; /** * The data to render, as described via the `columns` option. * @@ -87,7 +90,11 @@ export interface TableConfig { onRowSelectionChange?: (selection: DataType | undefined) => void; // Uncategorized - meta?: TableMeta; + /** + * Information about the table, for plugins, columns, and cells. + * Its type is inferred, and read back as `table.config.meta`. + */ + meta?: TableMeta & Meta; pagination?: Pagination; /** @@ -137,3 +144,30 @@ export interface TableConfig { } | (() => { key: string; adapter?: PreferencesAdapter }); } + +/** + * The config that `headlessTable` takes. + * + * `ColumnMetas` holds the `meta` of each column, in order, + * so that each column's `meta` is inferred. + * + * `TableConfig` itself has no such list: + * a mapped type there would make TypeScript compare every `Table` structurally. + * + * The plain list next to it lets TypeScript infer `DataType` from the columns too, + * which the mapped list alone does not. + */ +export type HeadlessTableConfig< + DataType, + ColumnMetas extends unknown[] = unknown[], + Meta = unknown, +> = Omit, 'columns'> & { + /** + * Configuration describing how the table will crawl through `data` + * and render it. Within this `columns` config, there will also be opportunities + * to set the behavior of columns when rendered + */ + columns: () => { + [K in keyof ColumnMetas]: ColumnConfig; + } & readonly ColumnConfig[]; +}; diff --git a/table/src/-private/js-helper.ts b/table/src/-private/js-helper.ts index de3fffc6..8b476b9f 100644 --- a/table/src/-private/js-helper.ts +++ b/table/src/-private/js-helper.ts @@ -2,7 +2,8 @@ import { assert } from '@ember/debug'; import { Table } from './table.ts'; -import type { TableConfig } from './interfaces'; +import type { HeadlessTableConfig, TableConfig } from './interfaces'; +import type { ColumnMetaOf } from './meta.ts'; /** * Represents a UI-less version of a table @@ -24,15 +25,20 @@ import type { TableConfig } from './interfaces'; * ``` * */ -export function headlessTable( +export function headlessTable< + T = unknown, + const ColumnMetas extends unknown[] = unknown[], + Meta = unknown, +>( parent: object, - options: TableConfig, -): Table { + options: HeadlessTableConfig, +): Table, Meta> { assert( `headlessTable requires a parent object as the first argument, usually \`this\`. ` + `The single-argument form was removed, because the table is no longer a Resource.`, options, ); - return new Table(parent, options); + // The meta types only shape what the table returns, so they come from the return type. + return new Table(parent, options as TableConfig); } diff --git a/table/src/-private/meta.ts b/table/src/-private/meta.ts new file mode 100644 index 00000000..b43f8bdd --- /dev/null +++ b/table/src/-private/meta.ts @@ -0,0 +1,39 @@ +/** + * The metas of columns that set one. + * A column without `meta` infers `unknown`, and would swallow the union. + */ +type ProvidedMetas = { + [K in keyof Metas]: unknown extends Metas[K] ? never : Metas[K]; +}[number]; + +type KeysOf = U extends unknown ? keyof U : never; + +type ValueAt = U extends unknown + ? K extends keyof U + ? U[K] + : never + : never; + +/** + * The type of `column.meta`, from the `meta` of each column in a config. + * + * A list written in place is a tuple, and its metas merge into one object: + * + * [{ meta: { align: 'left' } }, { meta: { align: 'right', width: 2 } }] + * → { align?: 'left' | 'right'; width?: 2 } + * + * A list with a declared type keeps that type: + * + * ColumnConfig[] → ReportMeta + */ +export type ColumnMetaOf = + number extends Metas['length'] + ? Metas[number] + : [ProvidedMetas] extends [never] + ? unknown + : { + -readonly [K in KeysOf>]?: ValueAt< + ProvidedMetas, + K + >; + }; diff --git a/table/src/-private/table.ts b/table/src/-private/table.ts index 8ae789e9..24a00ec4 100644 --- a/table/src/-private/table.ts +++ b/table/src/-private/table.ts @@ -51,7 +51,11 @@ const attachContainer = (element: Element, table: Table) => { * Symbol-keyed fields live on this interface, * because `isolatedDeclarations` cannot emit computed class members. */ -export interface Table { +export interface Table< + DataType = unknown, + ColumnMeta = unknown, + Meta = unknown, +> { /** * @private */ @@ -70,8 +74,14 @@ export interface Table { [ROW_META_KEY]: WeakMap, any>>; } +/** + * `ColumnMeta` is the type of `column.meta`, + * and `Meta` the type of `table.config.meta`, apart from the keys of `TableMeta`. + * + * `headlessTable` infers both from the config. + */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -export class Table { +export class Table { /** * @private * @@ -95,9 +105,9 @@ export class Table { scrollContainerElement?: HTMLElement; #parent: object; - #config: TableConfig; + #config: TableConfig; - constructor(parent: object, config: TableConfig) { + constructor(parent: object, config: TableConfig) { this.#parent = parent; this.#config = config; this[TABLE_KEY] = guidFor(this); @@ -142,7 +152,7 @@ export class Table { * * used by other private APIs */ - get config(): TableConfig { + get config(): TableConfig { return this.#config; } @@ -288,7 +298,10 @@ export class Table { map: (datum) => new Row(this, datum), }); - columns: MappedArray[], Column> = map(this, { + columns: MappedArray< + ColumnConfig[], + Column + > = map(this, { data: () => { const configFn = this.#config.columns; @@ -320,7 +333,7 @@ export class Table { return result; }, map: (config) => { - return new Column(this, { + return new Column(this, { ...DEFAULT_COLUMN_CONFIG, ...config, }); diff --git a/table/src/index.ts b/table/src/index.ts index e4182017..af8b79bf 100644 --- a/table/src/index.ts +++ b/table/src/index.ts @@ -12,8 +12,10 @@ export { deserializeSorts, serializeSorts } from './utils.ts'; *******************************/ export type { Column } from './-private/column.ts'; export type { + CellContext, ColumnConfig, ColumnKey, + HeadlessTableConfig, Pagination, PreferencesAdapter, TablePreferencesData as PreferencesData, diff --git a/table/src/plugins/-private/base.ts b/table/src/plugins/-private/base.ts index d0c66948..8013fc1c 100644 --- a/table/src/plugins/-private/base.ts +++ b/table/src/plugins/-private/base.ts @@ -309,7 +309,19 @@ export const preferences = { * This works recursively up the plugin tree up until a plugin has no requirements, and then * all columns from the table are returned. */ -function columnsFor( +function columnsFor( + table: Table, + requester?: Plugin, +): Column[] { + // Plugins hold columns of this same table, so they have its meta. + return resolveColumns(table, requester) as Column< + DataType, + ColumnMeta, + Meta + >[]; +} + +function resolveColumns( table: Table, requester?: Plugin, ): Column[] { @@ -432,10 +444,10 @@ export const columns = { * If a plugin class is provided, the hierarchy of column list modifications * will be respected. */ - next: ( - current: Column, + next: ( + current: Column, requester?: Plugin, - ): Column | undefined => { + ): Column | undefined => { const columns = requester ? columnsFor(current.table, requester) : columnsFor(current.table); @@ -464,10 +476,10 @@ export const columns = { * If a plugin class is provided, the hierarchy of column list modifications * will be respected. */ - previous: ( - current: Column, + previous: ( + current: Column, requester?: Plugin, - ): Column | undefined => { + ): Column | undefined => { const columns = requester ? columnsFor(current.table, requester) : columnsFor(current.table); @@ -494,10 +506,10 @@ export const columns = { * if a plugin class is provided, the hierarchy of column list modifications * will be respected. */ - before: ( - current: Column, + before: ( + current: Column, requester?: Plugin, - ): Column[] => { + ): Column[] => { const columns = requester ? columnsFor(current.table, requester) : columnsFor(current.table); @@ -513,10 +525,10 @@ export const columns = { * if a plugin class is provided, the hierarchy of column list modifications * will be respected. */ - after: ( - current: Column, + after: ( + current: Column, requester?: Plugin, - ): Column[] => { + ): Column[] => { const columns = requester ? columnsFor(current.table, requester) : columnsFor(current.table); diff --git a/table/src/plugins/column-reordering/helpers.ts b/table/src/plugins/column-reordering/helpers.ts index cfc5d1b9..cbc015d8 100644 --- a/table/src/plugins/column-reordering/helpers.ts +++ b/table/src/plugins/column-reordering/helpers.ts @@ -88,9 +88,13 @@ export const canMoveRight = ( * // Use the ordered columns for rendering or other operations * ``` */ -export const orderedColumnsFor = ( - table: Table, -): Column[] => { +export const orderedColumnsFor = < + DataType = unknown, + ColumnMeta = unknown, + Meta = unknown, +>( + table: Table, +): Column[] => { // Note: The meta.forTable API doesn't preserve the DataType generic from the table parameter. // This is a limitation of the current plugin meta system architecture. // We use a type assertion here because we know the columns come from the same table. @@ -98,5 +102,9 @@ export const orderedColumnsFor = ( table, ColumnReordering, ) as TableMeta; - return tableMeta.columnOrder.orderedColumns; + return tableMeta.columnOrder.orderedColumns as Column< + DataType, + ColumnMeta, + Meta + >[]; }; From 6b28ac6eb140bd97d972a804e26d3cc0ce2cd5a2 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:54:48 -0400 Subject: [PATCH 2/5] Mention the row type directly in ColumnConfig 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 did not fit Table. A type-only property that mentions T makes it unknown every time. Co-Authored-By: Claude Opus 5.5 (1M context) --- table/src/-private/interfaces/column.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/table/src/-private/interfaces/column.ts b/table/src/-private/interfaces/column.ts index 1968e60c..b3590ce6 100644 --- a/table/src/-private/interfaces/column.ts +++ b/table/src/-private/interfaces/column.ts @@ -5,6 +5,8 @@ import type { ColumnOptionsFor, SignatureFrom } from './plugins'; import type { Constructor } from '../private-types'; import type { ComponentLike, ContentValue } from '@glint/template'; +declare const rowType: unique symbol; + /** * What `value`, `options`, and a `Cell` receive. * @@ -92,6 +94,14 @@ export interface ColumnConfig< * ``` */ pluginOptions?: ColumnPluginOption[]; + + /** + * Type-only, never set. + * + * Without a direct mention of `T`, a list typed `ColumnConfig[]` + * gives `headlessTable` a row type that depends on the order TypeScript checks the program in. + */ + readonly [rowType]?: T; } export type ColumnKey = NonNullable['key']>; From 48347baf6d4fa897f06fd3c01c79d083a1a4cd00 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:05:47 -0400 Subject: [PATCH 3/5] Correct the comments on callback meta and HeadlessTableConfig 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) --- table/src/-private/interfaces/column.ts | 6 +++--- table/src/-private/interfaces/table.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/table/src/-private/interfaces/column.ts b/table/src/-private/interfaces/column.ts index b3590ce6..4b52266d 100644 --- a/table/src/-private/interfaces/column.ts +++ b/table/src/-private/interfaces/column.ts @@ -50,9 +50,9 @@ export interface ColumnConfig< * Optionally provide a function to determine the value of a row at this column * * `column.meta` is `unknown` here, and in `options`. - * If callbacks were typed with it, TypeScript would fix the column metas - * before it reads them, and a list where every column has a callback - * would lose its meta type. + * Typed with the column meta, it would be `any`: + * TypeScript takes the type from the plain column list in `HeadlessTableConfig`, + * where the column meta is `any`. */ value?: (context: CellContext>) => ContentValue; diff --git a/table/src/-private/interfaces/table.ts b/table/src/-private/interfaces/table.ts index 79a22d4a..8b773ae9 100644 --- a/table/src/-private/interfaces/table.ts +++ b/table/src/-private/interfaces/table.ts @@ -151,11 +151,13 @@ export interface TableConfig { * `ColumnMetas` holds the `meta` of each column, in order, * so that each column's `meta` is inferred. * - * `TableConfig` itself has no such list: - * a mapped type there would make TypeScript compare every `Table` structurally. + * `TableConfig` stays a plain interface, + * for code that annotates a config or reads `table.config`. * - * The plain list next to it lets TypeScript infer `DataType` from the columns too, - * which the mapped list alone does not. + * The plain list next to the mapped one lets TypeScript infer `DataType` + * from the columns too, which the mapped list alone does not. + * Its column meta is `any`, so that Cells that read a meta fit it. + * The mapped list checks each column's meta. */ export type HeadlessTableConfig< DataType, From cfbb714cbce0a7832bb75a654728292c9bd8b2d9 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:55:13 -0400 Subject: [PATCH 4/5] Infer the extra args of Cells, and let a declared type replace them A Cell can take args besides @row and @column, passed where it is rendered (). 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[] 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) --- .../1-get-started/typescript-and-glint.gjs.md | 54 +---- .../typing-meta-and-cells.gjs.md | 217 ++++++++++++++++++ .../-private/-type-tests/cell-args.test.ts | 176 ++++++++++++++ table/src/-private/column.ts | 18 +- table/src/-private/interfaces/column.ts | 11 +- table/src/-private/interfaces/table.ts | 16 +- table/src/-private/js-helper.ts | 7 +- table/src/-private/meta.ts | 62 +++++ table/src/-private/table.ts | 15 +- table/src/plugins/-private/base.ts | 58 +++-- .../src/plugins/column-reordering/helpers.ts | 8 +- test-app/tests/integration/cells-test.gts | 93 ++++++++ 12 files changed, 650 insertions(+), 85 deletions(-) create mode 100644 docs-app/src/templates/1-get-started/typing-meta-and-cells.gjs.md create mode 100644 table/src/-private/-type-tests/cell-args.test.ts create mode 100644 test-app/tests/integration/cells-test.gts diff --git a/docs-app/src/templates/1-get-started/typescript-and-glint.gjs.md b/docs-app/src/templates/1-get-started/typescript-and-glint.gjs.md index e4b47593..6cfc7897 100644 --- a/docs-app/src/templates/1-get-started/typescript-and-glint.gjs.md +++ b/docs-app/src/templates/1-get-started/typescript-and-glint.gjs.md @@ -71,58 +71,10 @@ class Demo { } ``` -### Column and table meta +### Column meta, table meta, and cell args -A column's `meta` and the table's `meta` are for information that is not tied to a row, for example the alignment of a column. -Their types are inferred from the config, so there is nothing to declare. - -```ts -class Demo { - table = headlessTable(this, { - columns: () => [ - { key: "name", meta: { align: "left" } }, - { key: "age", meta: { align: "right" } }, - ], - data: () => this.people, - meta: { currency: "EUR" }, - }); -} - -// column.meta: { align?: 'left' | 'right' } | undefined -// table.config.meta.currency: string -``` - -To check each column against a shape, use `satisfies`, or give the list a type: - -```ts -interface Alignment { - align?: "left" | "right"; -} - -class Demo { - // checks this column, and keeps the inferred type - table = headlessTable(this, { - columns: () => [ - { key: "name", meta: { align: "left" } satisfies Alignment }, - ], - data: () => this.people, - }); - - // every column in the list has `Alignment` as its meta - columns: ColumnConfig[] = [ - /* ... */ - ]; -} -``` - -A `Cell` component can ask for the meta it reads. -TypeScript then reports a column or table whose meta does not match: - -```ts -const AlignedCell: TOC<{ - Args: CellContext; -}> = ; -``` +These types are inferred too. +To check them, or to declare them yourself, see [Typing meta and cells](/docs/get-started/typing-meta-and-cells). ## In Templates diff --git a/docs-app/src/templates/1-get-started/typing-meta-and-cells.gjs.md b/docs-app/src/templates/1-get-started/typing-meta-and-cells.gjs.md new file mode 100644 index 00000000..bda07d06 --- /dev/null +++ b/docs-app/src/templates/1-get-started/typing-meta-and-cells.gjs.md @@ -0,0 +1,217 @@ +--- +title: Typing meta and cells +--- + +# Typing meta and cells + +`headlessTable` infers the types of a table from its config. +Most tables need no type annotations. +This page shows what is inferred, how to check it, and how to replace it with a type you declare. + +| What | Where you read it | Inferred from | +| ------------------- | ---------------------- | --------------------------------- | +| Row data | `row.data` | `data` | +| Column meta | `column.meta` | the `meta` of each column | +| Table meta | `table.config.meta` | the `meta` of the config | +| Extra args of cells | `` | the args of each `Cell` component | + +## Column meta + +A column's `meta` holds information that is not tied to a row, for example the alignment of the column. + +```ts +class Report { + table = headlessTable(this, { + columns: () => [ + { key: "name", meta: { align: "left" } }, + { key: "age", meta: { align: "right", exportWidth: 12 } }, + { key: "email" }, + ], + data: () => this.people, + }); +} +``` + +The metas of all columns merge into one type: + +```ts +table.columns[0].meta; +// { align?: 'left' | 'right'; exportWidth?: 12 } | undefined +``` + +Values keep their literal types: `'left'`, not `string`, and `12`, not `number`. +If no column has a `meta`, `column.meta` is `unknown`, and reading a key from it is a type error. + +### Check each column against a shape + +Use `satisfies` to check one column in place. +The inferred type stays as it is. + +```ts +interface Alignment { + align?: "left" | "right"; +} + +columns: () => [{ key: "name", meta: { align: "left" } satisfies Alignment }], +``` + +### Declare the type yourself + +Give the column list a type. +The table then uses that type, and every column is checked against it. + +```ts +import type { ColumnConfig } from "@universal-ember/table"; + +class Report { + columns: ColumnConfig[] = [ + { key: "name", meta: { align: "left" } }, + { key: "age", meta: { align: "right" } }, + ]; + + table = headlessTable(this, { + columns: () => this.columns, + data: () => this.people, + }); +} + +// table.columns[0].meta: Alignment | undefined +``` + +## Table meta + +The config's `meta` is for information about the whole table. +Its type is inferred, and the keys that the library itself uses (`TableMeta`) stay available. + +```ts +table = headlessTable(this, { + columns: () => [ + /* ... */ + ], + data: () => this.people, + meta: { + currency: "EUR", + updateCell: (person: Person, key: string, value: unknown) => { + /* ... */ + }, + }, +}); + +table.config.meta?.currency; // string | undefined +table.config.meta?.totalRowCount; // number | undefined, from TableMeta +``` + +A function in `meta` needs types on its parameters, because TypeScript cannot infer them there. + +The table meta is also available in `value`, `options`, and `Cell`, through `column.table.config.meta`. + +## Extra args of cells + +A `Cell` component always gets `@row` and `@column`. +It can take more args, which you pass where you render it: + +```gts +import type { TOC } from "@ember/component/template-only"; +import type { CellContext } from "@universal-ember/table"; + +const GroupedCell: TOC<{ + Args: CellContext & { groupBy: "day" | "week" }; +}> = ; + +class Report { + table = headlessTable(this, { + columns: () => [ + { key: "name", Cell: GroupedCell }, + { key: "age", Cell: AgeCell }, + ], + data: () => this.people, + }); +} +``` + +```gts +{{#each table.rows as |row|}} + {{#each table.columns as |column|}} + {{#if column.Cell}} + + {{/if}} + {{/each}} +{{/each}} +``` + +The table collects the extra args of all its Cells. +Glint then reports a missing `@groupBy`, or a value that is not `"day"` or `"week"`. +If two Cells take different args, the table needs both of them. + +### Declare the args yourself + +Give the column list a type with the args as the fourth type argument. +Use this when the Cells come from elsewhere, or when you want one place that names all args. + +```ts +interface ReportCellArgs { + groupBy: "day" | "week"; + onUpdate: (value: string) => void; +} + +columns: ColumnConfig[] = [ + { key: "name", Cell: GroupedCell }, + { key: "age", Cell: UpdateCell }, +]; +``` + +The type arguments of `ColumnConfig` are, in order: + +1. the row data +2. the column meta +3. the table meta +4. the extra args of cells + +Use `unknown` for the ones that you do not declare. + +## Writing a Cell component + +`CellContext` has the same order: row data, column meta, table meta. +Ask for the parts that the Cell reads. + +```gts +const AlignedCell: TOC<{ + Args: CellContext; +}> = ; +``` + +TypeScript reports a column whose meta does not match `Alignment`, and a table whose meta has no `currency`. + +## Code that takes any table + +A function that accepts `Column` or `Table` accepts columns and tables with any meta. + +A column whose Cell takes extra args needs `any` as the fourth type argument, +because its Cell cannot be rendered with `@row` and `@column` only: + +```ts +function keysOf(columns: Column[]) { + return columns.map((column) => column.key); +} +``` + +To read a meta, ask for it: + +```ts +function exportWidthOf(column: Column) { + return column.meta?.exportWidth; +} +``` + +## Limits + +- In `value` and `options`, `column.meta` is `unknown`, because TypeScript cannot give it the inferred type there. + Use the value from the column config itself. +- An inline `