From 20a501d1b1e939e675b454aa284957931f960e03 Mon Sep 17 00:00:00 2001 From: captain-mirage <241807724+captain-mirage@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:14:31 -0400 Subject: [PATCH] ts-sdk: stop Prettify from rewriting Uuid and branded primitives `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` 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`). --- .../src/lib/type_util.test-d.ts | 56 +++++++++++++++++++ .../bindings-typescript/src/lib/type_util.ts | 12 +++- 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 crates/bindings-typescript/src/lib/type_util.test-d.ts diff --git a/crates/bindings-typescript/src/lib/type_util.test-d.ts b/crates/bindings-typescript/src/lib/type_util.test-d.ts new file mode 100644 index 00000000000..80824b999d3 --- /dev/null +++ b/crates/bindings-typescript/src/lib/type_util.test-d.ts @@ -0,0 +1,56 @@ +import type { ConnectionId } from './connection_id'; +import type { Identity } from './identity'; +import type { Prettify } from './type_util'; +import type { TimeDuration } from './time_duration'; +import type { Timestamp } from './timestamp'; +import type { Uuid } from './uuid'; + +declare const brand: unique symbol; + +/** + * A branded primitive: an intersection of a primitive with a marker object. + * `Prettify` must leave it alone — mapping it produces a structural record of + * `String`'s members that no longer satisfies `string`. + */ +type UserId = string & { readonly [brand]: 'UserId' }; + +declare const userId: Prettify; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _brandedIsStillAString: string = userId; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _brandedKeepsItsBrand: UserId = userId; + +// Plain primitives round-trip unchanged. +declare const str: Prettify; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _str: string = str; +declare const num: Prettify; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _num: number = num; +declare const big: Prettify; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _big: bigint = big; + +// Every SATS wrapper class is passed through as the class, not as a +// structural record of its members. +declare const identity: Prettify; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _identity: Identity = identity; +declare const connectionId: Prettify; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _connectionId: ConnectionId = connectionId; +declare const timestamp: Prettify; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _timestamp: Timestamp = timestamp; +declare const timeDuration: Prettify; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _timeDuration: TimeDuration = timeDuration; +declare const uuid: Prettify; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _uuid: Uuid = uuid; + +// Object types are still flattened. +type Intersected = { a: string } & { b: number }; +declare const flattened: Prettify; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _flattened: { a: string; b: number } = flattened; diff --git a/crates/bindings-typescript/src/lib/type_util.ts b/crates/bindings-typescript/src/lib/type_util.ts index 942b76a1424..789595b24e8 100644 --- a/crates/bindings-typescript/src/lib/type_util.ts +++ b/crates/bindings-typescript/src/lib/type_util.ts @@ -3,20 +3,28 @@ import type { Identity } from './identity'; import type { ScheduleAt } from './schedule_at'; import type { TimeDuration } from './time_duration'; import type { Timestamp } from './timestamp'; +import type { Uuid } from './uuid'; type DoNotPrettify = | Identity | ConnectionId | Timestamp | TimeDuration - | ScheduleAt; + | ScheduleAt + | Uuid; /** * Utility to make TS show cleaner types by flattening intersections. + * + * Only object types are flattened. A non-object has no intersection to + * flatten, and mapping one is not always identity: `string & Brand` becomes a + * structural record of `String`'s members that no longer satisfies `string`. */ export type Prettify = T extends DoNotPrettify ? T - : { [K in keyof T]: T[K] } & {}; + : T extends string | number | boolean | bigint | symbol | null | undefined + ? T + : { [K in keyof T]: T[K] } & {}; /** * Helper function to sets a field in an object