Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/bench/src/row-model-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ export function createRowModelDiagnosticsController(
activeTransition?.cancel();
},
startDistinctDictionary(columnId) {
activeDistinct = rawModel.distinctValues(columnId as never, {
activeDistinct = rawModel.distinctValues(columnId, {
limit: 32,
});
void activeDistinct.finished.catch(() => undefined);
Expand Down Expand Up @@ -382,7 +382,7 @@ export function createRowModelDiagnosticsController(
async churnRetentionLimits() {
controller.churnRevisions(journalCapacity + 2);
for (const { id: columnId } of columns.slice(0, distinctCapacity + 1)) {
await rawModel.distinctValues(columnId as never, { limit: 1 }).finished;
await rawModel.distinctValues(columnId, { limit: 1 }).finished;
}
},
createRunSummary() {
Expand Down
12 changes: 11 additions & 1 deletion apps/website/content/docs/grid/editing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,21 @@ The proposal preserves the row, ID, column, and value correlations inferred from
| ------------- | -------------------------------------------------------------------- |
| `rowId` | stable ID of the edited row |
| `columnId` | exact ID of the edited column |
| `value` | committed value, inferred from `columnId` |
| `value` | committed value — see the note below on how precisely it's typed |
| `previousRow` | immutable row captured when editing began |
| `row` | complete proposed row |
| `changes` | partial row patch produced by the column's direct or computed setter |

How precisely `value` is typed depends on how you declare your columns. A column that declares an `accessor` carries its exact value type through, so `value` is correlated to `columnId` — narrow on `columnId` and `value` narrows with it. A column without one — the plain `PretableColumn<TRow>` shape used throughout these docs — has no static value type to carry, so `value` is `unknown` and you check it yourself:

```tsx
onRowChange={({ columnId, value }) => {
if (columnId === "quantity" && typeof value === "number" && value < 0) {
throw new Error("Quantity can't go negative");
}
}}
```

Explicit-model mode instead accepts `beforeRowChange={(changes) => ...}`. It may reject asynchronously; if it resolves, all proposals publish atomically through the supplied model. Rows mode does not accept `beforeRowChange`, and model mode does not accept `onRowChange`.

## Making a column editable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function AsyncEditingGrid() {
<code>onRowChange</code> rejects it — an inline error appears, the
editor stays open, and <kbd>Enter</kbd> retries.
</p>
<PretableSurface<StockItem>
<PretableSurface
ariaLabel="Stock items"
columns={columns}
getRowId={(row) => row.id}
Expand Down
2 changes: 1 addition & 1 deletion apps/website/lib/docs/__tests__/docs-api-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1420,7 +1420,7 @@ const TABLES: Record<string, TableBinding> = {
},
"grid/editing.mdx#The controlled model": {
unbound:
"Documents the anonymous payload object of `PretableSurfaceProps.onCellEdit`, which is declared inline and has no exported name.",
'Documents `PretableRowChange`, which IS exported — but as a mapped-type-indexed union (`{ [K in ColumnIdOf<TColumns>]: {…} }[ColumnIdOf<TColumns>]`), and the member reader here handles interfaces and inline object members only, so binding it fails with "is not an interface". This excuse is therefore about the READER, not the type: teach `resolveRefMembers` to read the inner object literal out of that shape and this table can and should bind. Until then it is unchecked, and it has already drifted once — the `value` row claimed the committed value was "inferred from `columnId`" while `ColumnValueOf` resolved to `never` for every accessor-less column, which is every column the docs corpus teaches.',
},
"headless/state-model.mdx#Row-model state": {
unbound:
Expand Down
8 changes: 5 additions & 3 deletions packages/core/core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,13 @@ export type ColumnsOf<TModel> = TModel extends {
// @public (undocumented)
export type ColumnType = "text" | "number" | "date" | "enum" | "boolean";

// @public (undocumented)
export type ColumnValueOf<TColumns, TColumnId extends ColumnIdOf<TColumns>> = TColumns extends readonly (infer TColumn)[] ? TColumn extends {
// @public
export type ColumnValueOf<TColumns, TColumnId extends ColumnIdOf<TColumns>> = [
TColumns extends readonly (infer TColumn)[] ? TColumn extends {
readonly id: TColumnId;
readonly accessor: (...args: never[]) => infer TValue;
} ? TValue : never : never;
} ? TValue : never : never
] extends [infer TResolved] ? [TResolved] extends [never] ? unknown : TResolved : never;

// @public
export function createColumnHelper<TRow extends object>(): PretableColumnHelper<TRow>;
Expand Down
8 changes: 5 additions & 3 deletions packages/react/react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,13 @@ export type ColumnsOf<TModel> = TModel extends {
// @public (undocumented)
export type ColumnType = "text" | "number" | "date" | "enum" | "boolean";

// @public (undocumented)
export type ColumnValueOf<TColumns, TColumnId extends ColumnIdOf<TColumns>> = TColumns extends readonly (infer TColumn)[] ? TColumn extends {
// @public
export type ColumnValueOf<TColumns, TColumnId extends ColumnIdOf<TColumns>> = [
TColumns extends readonly (infer TColumn)[] ? TColumn extends {
readonly id: TColumnId;
readonly accessor: (...args: never[]) => infer TValue;
} ? TValue : never : never;
} ? TValue : never : never
] extends [infer TResolved] ? [TResolved] extends [never] ? unknown : TResolved : never;

// @public
export interface CopyPayload {
Expand Down
67 changes: 67 additions & 0 deletions packages/row-model/src/__tests__/column-value-of.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/* eslint-disable @typescript-eslint/no-unused-vars -- the `_`-prefixed aliases
below ARE the assertions: each is a compile error if its `Expect<...>`
constraint fails, and nothing needs to read them at runtime. */
import { expectTypeOf, test } from "vitest";

import type { ColumnValueOf } from "../index";

type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false;
type Expect<T extends true> = T;

interface StockItem {
id: string;
item: string;
quantity: number;
}

/**
* The typed path: a column that declares an `accessor` keeps its exact value
* type. This is the guarantee everything else here must not cost.
*/
type Accessored = readonly [
{ readonly id: "item"; readonly accessor: (row: StockItem) => string },
{ readonly id: "quantity"; readonly accessor: (row: StockItem) => number },
];
type _AccessoredIsExact = Expect<
Equal<ColumnValueOf<Accessored, "quantity">, number>
>;

/**
* A tuple where only SOME columns declare an accessor. The accessor-less
* members must not widen the accessored ones — `ColumnValueOf` distributes
* over the column union, so a per-member fallback would union `unknown` into
* every answer and destroy the precision above.
*/
type Mixed = readonly [
{ readonly id: "item"; readonly accessor: (row: StockItem) => string },
{ readonly id: "quantity" },
];
type _MixedKeepsAccessored = Expect<
Equal<ColumnValueOf<Mixed, "item">, string>
>;
type _MixedFallsBackForTheRest = Expect<
Equal<ColumnValueOf<Mixed, "quantity">, unknown>
>;

/**
* The loose, id-keyed column shape the docs corpus actually teaches
* (`PretableColumn<TRow>[]`): no accessor, `id: string`. This used to resolve
* to `never`, which is assignable to everything — so every runtime guard
* written against such a value (`typeof value === "number"`) compiled while
* being type-level nonsense. `unknown` is the honest answer: it forces the
* guard instead of silently accepting it.
*/
type Loose = readonly { readonly id: string }[];
type _LooseIsUnknown = Expect<Equal<ColumnValueOf<Loose, string>, unknown>>;

test("an accessor-less column narrows under a typeof guard", () => {
const value = 3 as ColumnValueOf<Loose, string>;
// `never` would make this branch vacuous; `unknown` makes it real.
expectTypeOf(value).toBeUnknown();
if (typeof value === "number") {
expectTypeOf(value).toBeNumber();
}
});
49 changes: 38 additions & 11 deletions packages/row-model/src/column-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,17 +361,44 @@ export type ColumnIdOf<TColumns> = TColumns extends readonly (infer TColumn)[]
: never
: never;

/** @public */
export type ColumnValueOf<
TColumns,
TColumnId extends ColumnIdOf<TColumns>,
> = TColumns extends readonly (infer TColumn)[]
? TColumn extends {
readonly id: TColumnId;
readonly accessor: (...args: never[]) => infer TValue;
}
? TValue
: never
/**
* The value type of the column with the given id.
*
* An accessored column resolves to its exact declared type. A column that
* declares no accessor — the loose, id-keyed shape — resolves to `unknown`,
* not `never`: the value genuinely isn't known statically, and `never` is
* assignable to everything, so it silently accepted (and made vacuous) every
* runtime guard written against it. `unknown` forces the guard instead.
*
* Two pieces of the shape below are load-bearing:
*
* - The inner `never` stays `never`. The lookup distributes over the column
* union, so for a mixed tuple every non-matching member contributes to the
* result union; `never` is the union identity, so those members vanish and
* the matching member's type survives exactly. Falling back to `unknown`
* per member would union `unknown` into every answer and destroy that.
* - The fallback is applied through `[...] extends [infer TResolved]`, which
* binds the resolved type once (no repetition, no extra exported alias)
* while the tuple wrapper blocks distribution. A naked `extends infer` would
* distribute, and distributing over `never` short-circuits the whole
* conditional to `never` — which is precisely the case this fallback exists
* to catch.
*
* @public
*/
export type ColumnValueOf<TColumns, TColumnId extends ColumnIdOf<TColumns>> = [
TColumns extends readonly (infer TColumn)[]
? TColumn extends {
readonly id: TColumnId;
readonly accessor: (...args: never[]) => infer TValue;
}
? TValue
: never
: never,
] extends [infer TResolved]
? [TResolved] extends [never]
? unknown
: TResolved
: never;

/** @public */
Expand Down