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 RowObj → InferTypeOfRow, TypeBuilder → InferTypeOfTypeBuilder:
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.
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| undefinedvalues (someColumn: string | undefined).I expected that inferred table row types would also have the new truly optional keys.
Minimal Reproduction (by me)
Reducer/procedure params do work
Root Cause Hypothesis
PR #4940 added optional-key splitting only to
InferTypeOfRow, but table-row inference hits other paths andInferTypeOfRowitself fails once columns are wrapped inColumnBuilder(which is always the case for real table rows).1.
InferTypeOfRowdetects optionality viaOptionBuilder, but table columns are widened toTypeBuilderOptionalRowKeysonly marks a key optional when the collapsed column type extendsOptionBuilder:RowBuilderalways wraps bare type builders inColumnBuilder:But
ColumnBuilder.typeBuilderis typed asTypeBuilder<Type, SpacetimeType>, erasing theOptionBuildersubclass. So forsomeColumn: t.string().optional(),CollapseColumn<...>becomesTypeBuilder<string | undefined, ...>,extends OptionBuilder<any>is false, and the key stays required (with valuestring | undefined).Reducer params avoid this because #4940 added a separate helper that keys off
undefinedin the value type:undefined extends (string | undefined)is true even afterColumnBuilderwidening — so reducer calls work, butInferTypeOfRowdoes not.The PR's type test only covers a bare
RowObj(noColumnBuilderwrapping), so it passes while real table rows fail:2.
Infer<typeof table.rowType>never goes throughInferTypeOfRowInfer<T>dispatchesRowObj→InferTypeOfRow,TypeBuilder→InferTypeOfTypeBuilder:table().rowTypeis aRowBuilder, soInfer<typeof testTable.rowType>resolves toRowBuilder's type parameter, which still uses the old all-keys-required privateRowType:3. SDK
RowType<TableDef>is also affectedExported
RowTypeintable.tsdelegates to the brokenInferTypeOfRowovercolumns(which come fromRowBuilder.row, i.e.ColumnBuilder-wrapped):So client
useTable/onInsert/insertrow types have the same bug.Suggested fix direction
InferTypeOfRowuse the sameundefined extends InferTypeOfTypeBuilder<...>check asInferTypeOfParams(or combine both checks).RowBuilder's type parameter (andProductBuilder'sObjectType) to useInferTypeOfRowinstead of the private all-requiredRowType/ObjectType.table()/RowBuilder.row/RowType<TableDef>, not only bareRowObjliterals.