ts-sdk: stop Prettify from rewriting Uuid and branded primitives - #5971
Open
captain-mirage wants to merge 1 commit into
Open
captain-mirage wants to merge 1 commit into
captain-mirage wants to merge 1 commit into
Conversation
`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`).
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description of Changes
Prettify(src/lib/type_util.ts:17) is documented as "make TS show cleaner types byflattening intersections", but it maps every type it is given, including types that have
no intersection to flatten:
InferTypeOfTypeBuilder(src/lib/type_builders.ts:21) runs every column type in everytable through it:
Two things fall through the object branch that should not.
1.
Uuidis the one SATS wrapper missing fromDoNotPrettifyIdentity,ConnectionId,TimestampandTimeDurationare exempt.Uuid— the samekind 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 ofUuid's members:on
1906706:with this change:
(Reproduce by assigning a value of
Rowto1and reading theTS2322message, or byhovering
Rowin an editor.)To be straight about severity:
Uuidhas noprivate/#instance members today, so theexpansion is structurally identical to
Uuidand nothing currently fails to compile.What it costs today is every hover, every
.d.tsread and every diagnostic that mentions aUuidcolumn — the exact thingDoNotPrettifyexists to prevent, applied inconsistentlyacross five wrapper classes out of six. What it costs tomorrow is real: the moment
Uuidgains a private field (a cached hex string, say —
Identity.isEqualalready round-tripsthrough
toHexString()) or an accessor,Prettify<Uuid>stops being assignable toUuidand 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
Prettifyis exported from the package root, and a branded primitive is a normal thing tohand a type utility:
on
1906706:The mapped type expands the brand into a ~50-member structural record of
String'smethods that is no longer a
stringand no longer carries the brand nominally.The fix
Two notes on the primitive branch:
stringalreadyyields
string, andstring & {}reduces tostring.Prettify<string>was alreadystring, and staysstring. The branch only changes intersections of a primitive witha brand.
T extends object ? … : T. I checked:string & Brandsatisfies
objectandstring, so an object-first guard leaves the branded casebroken. The primitive test has to come first.
The
& {}idiom is untouched, so the@typescript-eslint/no-empty-object-typeconfiguration (
{ allowObjectTypes: 'always' }) still covers it.Related, not fixed here
#5507 (
[ts] inconsistent optional key inference in 2.6.1) is about the sameInferTypeOfRowcall site but a different cause —ColumnBuilderwidening erasing theOptionBuildersubclass so optional columns infer as required keys. This change does nottouch that and does not fix it.
Alternative considered
Add only the
Uuidline and leavePrettifyalone. That fixes the reachable case and isa strictly smaller diff. I included the primitive guard as well because
Prettifyisexported 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
Uuiddid. If you would rather take just the
Uuidline, 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-limitreports the sameesm min (brotli)21.26 kB before and after).
The types it changes become more precise, never less:
Prettify<Uuid>goes from astructurally identical record to
Uuiditself, andPrettify<string & Brand>goes from arecord that did not satisfy
stringto the branded type. Any consumer relying on theexpanded form was relying on a structurally equivalent type, so nothing that compiled
before stops compiling. Verified by building the
test-appworkspace and eight frameworktemplates unchanged.
Rollback safety impact
n/a
Expected complexity level and risk
Prettifyhas roughly 80 references across 13 files insrc/and sits underSetField,InferTypeOfRow,InferTypeOfParams,RowTypeand thefive framework
useTable.tsmirrors (react,vue,svelte,solid,angular), sothe blast radius is the whole public type surface even though the change is narrow. The
things worth a reviewer's eye:
DoNotPrettifyand before the mapped type, and theorder matters (see the
T extends objectnote above);Prettifyis a distributive conditional type, and it still is — adding a branch doesnot change how it distributes over unions;
Uuidintotype_util.tsis erased at build (import type,verbatimModuleSyntax), so it introduces no runtime cycle.Testing
crates/bindings-typescript/src/lib/type_util.test-d.tsin the style of theexisting
*.test-d.tsfiles (plain assignability declarations with theeslint-disable-next-line @typescript-eslint/no-unused-varsconvention). It assertsthat a branded primitive survives as both
stringand its branded type, that plainprimitives 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 viapnpm build.masterfor the right reason: exactlythe two branded-primitive assertions error with
TS2322, printing the ~50-memberStringexpansion. TheUuidassertion passes onmastertoo, because theexpansion is structurally identical today — the
Uuidhalf of this change is atype-display and future-proofing fix, not a compile error being fixed, and I would
rather say that than overclaim.
pnpm build:typesclean.pnpm test— 30 files, 316 tests, 0 failures.pnpm lintclean.pnpm buildclean.pnpm size— all 13 budgets green andunchanged from master.
pnpm --filter '@clockworklabs/test-app' run build(tsc -b && vite build) andpnpm -r run buildovertemplates/{react,vue,svelte,solid,angular,tanstack,basic,browser}-tsall clean, so no consumer of
RowType/Infer/useTableregressed.a UUID column now reads
Uuidin the editor would be worth having — I only have thesynthetic
Infer<typeof row>repro above.