clockworklabs / clockworklabs/SpacetimeDB
[ts] inconsistent optional key inference in 2.6.1
@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 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
InferTypeOfRowuse the sameundefined extends InferTypeOfTypeBuilder<...>check asInferTypeOfParams(or combine both checks). - Change
RowBuilder's type parameter (andProductBuilder'sObjectType) to useInferTypeOfRowinstead of the private all-requiredRowType/ObjectType. - Add regression tests that go through
table()/RowBuilder.row/RowType<TableDef>, not only bareRowObjliterals.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.