clockworklabs / clockworklabs/SpacetimeDB

[ts] inconsistent optional key inference in 2.6.1

Open
#5,507 0 comments 0 reactions 1 assignee View on GitHub

@JasonAtClockwork is already working on this.

Since Aug 4, 2026.

Dominant language
Rust
Stars
25.2k
Forks
1.1k
Avg merge
2d 7h
Merged PRs (30d)
46

Description

Summary (by me)

PR #4940 (released in 2.6.1) says that Option<T> / t.optional() fields are inferred as truly optional object keys (key?: T | undefined) in TypeScript. That works for reducer/procedure call params, but table row types still infer optional columns as required keys with | undefined values (someColumn: string | undefined).

I expected that inferred table row types would also have the new truly optional keys.

Minimal Reproduction (by me)

import { table, t, type Infer } from 'spacetimedb';

const testTable = table(
  { accessor: 'test_table', public: true },
  {
    id: t.uuid(),
    someColumn: t.string().optional(),
  }
);

type TestTableRow = Infer<typeof testTable.rowType>;
/**
 * actual:
 *   type TestTableRow = {
 *     id: Uuid;
 *     someColumn: string | undefined;  // required key
 *   };
 *
 * expected:
 *   type TestTableRow = {
 *     id: Uuid;
 *     someColumn?: string | undefined;
 *   };
 */

const row: TestTableRow = { id: someUuid }; // ts error: Property 'someColumn' is missing ... but required

Reducer/procedure params do work

const reducerParamsObject = { 
  id: t.uuid(),
  someColumn: t.string().optional(),
}

const testReducer = spacetimedb.reducer(
  { name: "test_reducer" },
  reducerParamsObject,
  (ctx, args) => {
    // inferred correctly here, too, prob cause InferTypeOfParams is used
  }
)

type TestReducerArgs = InferTypeOfParams<typeof reducerParamsObject>;
/**
 * actual and expected:
 *   type TestReducerArgs = {
 *     id: Uuid;
 *     someColumn?: string | undefined;
 *   }
 */

Root Cause Hypothesis

⚠️ llm-generated; looks right to me but be aware

PR #4940 added optional-key splitting only to InferTypeOfRow, but table-row inference hits other paths and InferTypeOfRow itself fails once columns are wrapped in ColumnBuilder (which is always the case for real table rows).

1. InferTypeOfRow detects optionality via OptionBuilder, but table columns are widened to TypeBuilder

OptionalRowKeys only marks a key optional when the collapsed column type extends OptionBuilder:

type OptionalRowKeys<T extends RowObj> = {
  [K in keyof T & string]-?: CollapseColumn<T[K]> extends OptionBuilder<any>
    ? K
    : never;
}[keyof T & string];

export type InferTypeOfRow<T extends RowObj> = Prettify<
  {
    [K in RequiredRowKeys<T>]: InferTypeOfTypeBuilder<CollapseColumn<T[K]>>;
  } & {
    [K in OptionalRowKeys<T>]?: InferTypeOfTypeBuilder<CollapseColumn<T[K]>>;
  }
>;

RowBuilder always wraps bare type builders in ColumnBuilder:

    const mappedRow = Object.fromEntries(
      Object.entries(row).map(([colName, builder]) => [
        colName,
        builder instanceof ColumnBuilder
          ? builder
          : new ColumnBuilder(builder, {}),
      ])
    ) as CoerceRow<Row>;

But ColumnBuilder.typeBuilder is typed as TypeBuilder<Type, SpacetimeType>, erasing the OptionBuilder subclass. So for someColumn: t.string().optional(), CollapseColumn<...> becomes TypeBuilder<string | undefined, ...>, extends OptionBuilder<any> is false, and the key stays required (with value string | undefined).

Reducer params avoid this because #4940 added a separate helper that keys off undefined in the value type:

type OptionalParamKeys<T extends RowObj> = {
  [K in keyof T & string]-?: undefined extends InferTypeOfTypeBuilder<
    CollapseColumn<T[K]>
  >
    ? K
    : never;
}[keyof T & string];

export type InferTypeOfParams<T extends RowObj> = Prettify< /* ... */ >;

undefined extends (string | undefined) is true even after ColumnBuilder widening — so reducer calls work, but InferTypeOfRow does not.

The PR's type test only covers a bare RowObj (no ColumnBuilder wrapping), so it passes while real table rows fail:

const rowOptionOptional = {
  foo: t.string().optional().optional(),
};
type RowOptionOptional = InferTypeOfRow<typeof rowOptionOptional>;
const _rowOptionOptionalOmitted: RowOptionOptional = {};
2. Infer<typeof table.rowType> never goes through InferTypeOfRow

Infer<T> dispatches RowObjInferTypeOfRow, TypeBuilderInferTypeOfTypeBuilder:

export type Infer<T> = T extends RowObj
  ? InferTypeOfRow<T>
  : T extends TypeBuilder<any, any>
    ? InferTypeOfTypeBuilder<T>
    : never;

table().rowType is a RowBuilder, so Infer<typeof testTable.rowType> resolves to RowBuilder's type parameter, which still uses the old all-keys-required private RowType:

type RowType<Row extends RowObj> = {
  [K in keyof Row]: InferTypeOfTypeBuilder<CollapseColumn<Row[K]>>;
};
export class RowBuilder<Row extends RowObj> extends TypeBuilder<
  RowType<CoerceRow<Row>>,
3. SDK RowType<TableDef> is also affected

Exported RowType in table.ts delegates to the broken InferTypeOfRow over columns (which come from RowBuilder.row, i.e. ColumnBuilder-wrapped):

export type RowType<TableDef extends Pick<UntypedTableDef, 'columns'>> =
  InferTypeOfRow<TableDef['columns']>;

So client useTable / onInsert / insert row types have the same bug.

Suggested fix direction
  • Make InferTypeOfRow use the same undefined extends InferTypeOfTypeBuilder<...> check as InferTypeOfParams (or combine both checks).
  • Change RowBuilder's type parameter (and ProductBuilder's ObjectType) to use InferTypeOfRow instead of the private all-required RowType / ObjectType.
  • Add regression tests that go through table() / RowBuilder.row / RowType<TableDef>, not only bare RowObj literals.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.