Skip to content

ts-sdk: stop Prettify from rewriting Uuid and branded primitives - #5971

Open
captain-mirage wants to merge 1 commit into
clockworklabs:masterfrom
captain-mirage:fix/ts-prettify-non-object-types
Open

captain-mirage wants to merge 1 commit into
clockworklabs:masterfrom
captain-mirage:fix/ts-prettify-non-object-types

Conversation

@captain-mirage

Copy link
Copy Markdown

Description of Changes

Prettify (src/lib/type_util.ts:17) is documented as "make TS show cleaner types by
flattening intersections", but it maps every type it is given, including types that have
no intersection to flatten:

type DoNotPrettify = Identity | ConnectionId | Timestamp | TimeDuration | ScheduleAt;

export type Prettify<T> = T extends DoNotPrettify
  ? T
  : { [K in keyof T]: T[K] } & {};

InferTypeOfTypeBuilder (src/lib/type_builders.ts:21) runs every column type in every
table
through it:

export type InferTypeOfTypeBuilder<T extends TypeBuilder<any, any>> =
  T extends TypeBuilder<infer U, any> ? Prettify<U> : never;

Two things fall through the object branch that should not.

1. Uuid is the one SATS wrapper missing from DoNotPrettify

Identity, ConnectionId, Timestamp and TimeDuration are exempt. Uuid — the same
kind of one-field wrapper class, added to the SDK after the list was written — is not.
So a t.uuid() column is rewritten into a structural record of Uuid's members:

const row = { id: t.uuid(), at: t.timestamp(), name: t.string() };
type Row = Infer<typeof row>;

on 1906706:

{ name: string; at: Timestamp; id: { __uuid__: bigint; toHexString: () => string;
  toString: () => string; asBigInt: () => bigint; toBytes: () => Uint8Array;
  getVersion: () => UuidVersion; getCounter: () => number;
  compareTo: (other: Uuid) => number; }; }

with this change:

{ name: string; at: Timestamp; id: Uuid; }

(Reproduce by assigning a value of Row to 1 and reading the TS2322 message, or by
hovering Row in an editor.)

To be straight about severity: Uuid has no private/# instance members today, so the
expansion is structurally identical to Uuid and nothing currently fails to compile.
What it costs today is every hover, every .d.ts read and every diagnostic that mentions a
Uuid column — the exact thing DoNotPrettify exists to prevent, applied inconsistently
across five wrapper classes out of six. What it costs tomorrow is real: the moment Uuid
gains a private field (a cached hex string, say — Identity.isEqual already round-trips
through toHexString()) or an accessor, Prettify<Uuid> stops being assignable to Uuid
and every inferred row type containing a UUID column breaks at once. A one-line addition
to an existing allow-list removes that trip-wire.

2. A branded primitive does break today

Prettify is exported from the package root, and a branded primitive is a normal thing to
hand a type utility:

declare const brand: unique symbol;
type UserId = string & { readonly [brand]: 'UserId' };

declare const userId: Prettify<UserId>;
const s: string = userId;

on 1906706:

error TS2322: Type '{ readonly [x: number]: string; toString: () => string;
  charAt: (pos: number) => string; charCodeAt: (index: number) => number;
  concat: (...strings: string[]) => string; ... 47 more ...;
  readonly [brand]: "UserId"; }' is not assignable to type 'string'.

The mapped type expands the brand into a ~50-member structural record of String's
methods that is no longer a string and no longer carries the brand nominally.

The fix

type DoNotPrettify =
  | Identity
  | ConnectionId
  | Timestamp
  | TimeDuration
  | ScheduleAt
  | Uuid;

export type Prettify<T> = T extends DoNotPrettify
  ? T
  : T extends string | number | boolean | bigint | symbol | null | undefined
    ? T
    : { [K in keyof T]: T[K] } & {};

Two notes on the primitive branch:

  • It is a no-op for plain primitives. A homomorphic mapped type over string already
    yields string, and string & {} reduces to string. Prettify<string> was already
    string, and stays string. The branch only changes intersections of a primitive with
    a brand.
  • It cannot be written as T extends object ? … : T. I checked: string & Brand
    satisfies object and string, so an object-first guard leaves the branded case
    broken. The primitive test has to come first.

The & {} idiom is untouched, so the @typescript-eslint/no-empty-object-type
configuration ({ allowObjectTypes: 'always' }) still covers it.

Related, not fixed here

#5507 ([ts] inconsistent optional key inference in 2.6.1) is about the same
InferTypeOfRow call site but a different cause — ColumnBuilder widening erasing the
OptionBuilder subclass so optional columns infer as required keys. This change does not
touch that and does not fix it.

Alternative considered

Add only the Uuid line and leave Prettify alone. That fixes the reachable case and is
a strictly smaller diff. I included the primitive guard as well because Prettify is
exported public API, the branded case is a hard error rather than a display problem, and
the guard is what stops the next wrapper type from silently regressing the way Uuid
did. If you would rather take just the Uuid line, say so and I will drop the rest.

API and ABI breaking changes

None, and no runtime change at all — this is a type-level edit plus a type-test file, and
the emitted bundles are byte-identical (size-limit reports the same esm min (brotli)
21.26 kB before and after).

The types it changes become more precise, never less: Prettify<Uuid> goes from a
structurally identical record to Uuid itself, and Prettify<string & Brand> goes from a
record that did not satisfy string to the branded type. Any consumer relying on the
expanded form was relying on a structurally equivalent type, so nothing that compiled
before stops compiling. Verified by building the test-app workspace and eight framework
templates unchanged.

Rollback safety impact

n/a

Expected complexity level and risk

  1. The diff is six lines, but Prettify has roughly 80 references across 13 files in
    src/ and sits under SetField, InferTypeOfRow, InferTypeOfParams, RowType and the
    five framework useTable.ts mirrors (react, vue, svelte, solid, angular), so
    the blast radius is the whole public type surface even though the change is narrow. The
    things worth a reviewer's eye:
  • the new branch is tested after DoNotPrettify and before the mapped type, and the
    order matters (see the T extends object note above);
  • Prettify is a distributive conditional type, and it still is — adding a branch does
    not change how it distributes over unions;
  • the new type-only import of Uuid into type_util.ts is erased at build (import type,
    verbatimModuleSyntax), so it introduces no runtime cycle.

Testing

  • Added crates/bindings-typescript/src/lib/type_util.test-d.ts in the style of the
    existing *.test-d.ts files (plain assignability declarations with the
    eslint-disable-next-line @typescript-eslint/no-unused-vars convention). It asserts
    that a branded primitive survives as both string and its branded type, that plain
    primitives round-trip, that all six SATS wrapper classes pass through as themselves,
    and that object intersections are still flattened. These files are checked by
    pnpm build:types, which CI runs via pnpm build.
  • Verified the new type test fails on unmodified master for the right reason: exactly
    the two branded-primitive assertions error with TS2322, printing the ~50-member
    String expansion. The Uuid assertion passes on master too, because the
    expansion is structurally identical today — the Uuid half of this change is a
    type-display and future-proofing fix, not a compile error being fixed, and I would
    rather say that than overclaim.
  • pnpm build:types clean. pnpm test — 30 files, 316 tests, 0 failures.
    pnpm lint clean. pnpm build clean. pnpm size — all 13 budgets green and
    unchanged from master.
  • Blast radius: pnpm --filter '@clockworklabs/test-app' run build (tsc -b && vite build) and pnpm -r run build over templates/{react,vue,svelte,solid,angular,tanstack,basic,browser}-ts
    all clean, so no consumer of RowType/Infer/useTable regressed.
  • A maintainer sanity check that the inferred row type for a real generated module with
    a UUID column now reads Uuid in the editor would be worth having — I only have the
    synthetic Infer<typeof row> repro above.

`Prettify` is documented as flattening intersections, but it maps every
type it is given, including types that have no intersection to flatten.
Two consequences are visible through `Infer` / `InferTypeOfRow`, which run
every column type in a table through `Prettify`:

  - `Uuid` is the only SATS wrapper class missing from `DoNotPrettify`, so a
    `t.uuid()` column is rewritten into a structural record of `Uuid`'s
    members. A row of `{ id: t.uuid(), at: t.timestamp() }` infers as

        { at: Timestamp; id: { __uuid__: bigint; toHexString: () => string;
          toString: () => string; asBigInt: () => bigint;
          toBytes: () => Uint8Array; getVersion: () => UuidVersion;
          getCounter: () => number; compareTo: (other: Uuid) => number } }

    `Timestamp` survives as `Timestamp` because it is on the list; `Uuid`,
    which was added to the SDK later, never was. The two are structurally
    identical today so nothing fails to compile, but the inferred type,
    every hover and every diagnostic mentioning a `Uuid` column carries the
    expansion, and it stops being merely cosmetic the moment `Uuid` gains a
    `private`/`#` member or an accessor.

  - A branded primitive does break today. `Prettify<string & Brand>` is a
    ~50-member structural record of `String`'s methods that is no longer
    assignable to `string`.

Pass `Uuid` through with the other wrappers, and short-circuit non-object
types. The primitive branch is a no-op for plain primitives -- a homomorphic
mapped type over `string` already yields `string` -- so it only changes
intersections of a primitive with a brand. It must be tested before the
object branch and cannot be written as `T extends object`, because
`string & Brand` satisfies `object` as well as `string`.

`src/lib/type_util.test-d.ts` covers both, in the style of the other
`*.test-d.ts` files (checked by `pnpm build:types`).
@CLAassistant

CLAassistant commented Sep 22, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants