Skip to content

[ts] inconsistent optional key inference in 2.6.1 #5507

Description

@reilly-freret

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions