Skip to content

Make the published types portable across package instances - #6200

Draft
candrewlee14 wants to merge 6 commits into
drizzle-team:rc5from
candrewlee14:sql-types-portable
Draft

Make the published types portable across package instances#6200
candrewlee14 wants to merge 6 commits into
drizzle-team:rc5from
candrewlee14:sql-types-portable

Conversation

@candrewlee14

@candrewlee14 candrewlee14 commented Aug 29, 2026

Copy link
Copy Markdown

What

Two copies of drizzle-orm on disk produce two declaration sites for every class, and TypeScript compares classes carrying private/protected members nominally — the members must originate from the same declaration. So any such member surviving into the emitted .d.ts makes its type non-portable: a value from copy A is not assignable to the same type from copy B.

That situation is routine — a transitive dep pinning a different version, a pnpm peer split, a linked tarball — and it produces the maximally unhelpful error, where both sides print identically:

Type 'SQL<unknown>' is not assignable to type 'SQL<unknown>'.

This strips those members from the published declarations. The build already sets stripInternal: true, so /** @internal */ is all that is needed; nothing observable is lost, because none of it was callable.

Commits

Each is self-contained and moves the measured surface:

  1. SQLcollectSQL, mapInlineParam, and BuildQueryConfig.codecs (which reached CodecsCollection's protected resolveTypes), plus GetDecoderResult matching on shape rather than naming the Column class.
  2. The portability matrix — generalises the one-off check into a table over the public surface. Asserts both directions: a portable type regressing fails, and a recorded leak becoming portable also fails, prompting the flag to be flipped.
  3. Name#brand, Param#brand, CodecsCollection#resolveTypes.
  4. PgColumnBuilder internals — reachable from every pg table and column via PgColumn#toBuilder().
  5. View identity membersSymbol.for-keyed configs and brand members.
  6. Dialect query builders and AggregatedField#tablebuildSelection, buildJoins, buildWithCTE and friends, plus one member a level down.

Every change here is load-bearing

I checked rather than assumed: each was reverted individually against the packed artifact, rebuilt, and re-measured. Two results worth stating, because both contradicted what I expected:

  • GetDecoderResult looked like an optional tidy-up. It is not. Naming the Column class in that conditional makes it depend on relating one install's Column to another's, and reverting it regresses Column, CodecsCollection, Name, and the mysql table/column/dialect types — six in total.
  • BuildQueryConfig.codecs is the one marker that becomes redundant later, once CodecsCollection#resolveTypes is stripped in commit 3. It is still required at commit 1 for SQL to be portable on its own, so it stays, with a comment saying as much.

AggregatedField#table is worth a second look

The union inside BuildRelationalQueryResult['selection'] names AggregatedField directly. A protected member on that class makes it nominal, which makes the whole recursive selection type non-portable, which propagates out through every dialect's mapperGenerators.

The diagnostic surfaces at the recursive type rather than at the member, and reads like a compiler recursion limit. It is not — it is one leaked member, and stripping it fixes the whole chain. I flag it because I initially misdiagnosed it that way myself.

The Symbol.for keys are worth a second look

PgView declared [PgViewConfig] unmarked. A Symbol.for key is a distinct computed key per declaration site, so copy A's PgView carries a property copy B's does not — even though Symbol.for guarantees the runtime symbol is shared. The type contradicted the runtime.

This is already handled correctly elsewhere: every Symbol.for key Table declares is @internal and stripped, which is exactly why the table and column types were portable while the views were not.

Result

The matrix now probes 30 types and covers every file this change touches — including the cockroach, mssql and singlestore dialects and views, which were previously edited but unguarded by any probe.

Of those 30, 22 are portable after this PR. The pg/mysql/sqlite dialects stop one step short on EmptyFilter's unique symbol, which is an API decision and is proposed separately in #6205 — taking it to 25.

NodePgDatabase and PgSelect remain non-portable

NodePgDatabase, PgSelect, and the cockroach / mssql / singlestore dialects are recorded in the matrix with the member that currently blocks each. The last three reach their session class and inherit a chain of nominal members through the prepared-query and transaction classes; I tried one round of stripping it, it did not converge, and I stopped rather than repeat a sweep that flips nothing.

I chased both further than this PR goes. Past their nominal members each ends at a generic type deferred on an unresolved parameter — BuildQueryResult<..., TConfig, ...> for the database object, CheckTableLikeSelection<TJoinedTable> for the select builder — where the compiler cannot relate keyof X or X extends Y across two declaration sites. I stripped every nominal member on the select path to check, and it did not flip, so those markers are not in this PR.

I would not state flatly that those are unfixable, because I made exactly that call about the recursive selection type above and was wrong: it turned out to be one leaked member wearing a confusing diagnostic. Treat them as unresolved, not as proven walls.

Verification

Every commit was verified against a real bun --bun run scripts/build.ts && npm run pack, since the bug lives in what is emitted, not what is written.

  • Full drizzle-orm suite green (27 files, 1344 tests) and test:types clean at each commit.
  • drizzle-kit and drizzle-seed typecheck clean against the modified ORM.
  • The matrix test guards the packed tarball and runs in the CI orm shard, which already provisions it.

cursoragent and others added 5 commits August 29, 2026 20:03
Strip implementation-only query-builder details from published declarations, infer decoder results structurally, and compile two physical package copies to prevent regressions.
The single SQL assignment only guarded one type. Replace it with a table of
public types probed in one tsc program, asserting both directions: a portable
type regressing fails, and a known leak becoming portable fails too, prompting
the flag to be flipped.

Records the measured state of the surface, annotating each known leak with the
private/protected member responsible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qd99Mmmi1fZkKQiV3u1jXy
Name#brand, Param#brand and CodecsCollection#resolveTypes are implementation
details that reached the emitted .d.ts as protected members, making all three
types nominal and so non-portable between two installs of the package.

resolveTypes is declared as a field rather than left as a constructor parameter
property: on a parameter the @internal marker survives into the emitted
constructor signature as a stray comment.

Flips Param, Name and CodecsCollection to portable in the matrix. The three
dialects do not follow -- with the nominal blocker gone they fail structurally
instead, on the anonymous recursive BuildRelationalQueryResult['selection'],
which is recorded as their blocker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qd99Mmmi1fZkKQiV3u1jXy
PgColumn#toBuilder returns a PgColumnBuilder, so the builder's private and
protected members are part of PgColumn's published shape, and PgTable's
_.columns index signature carries them up to the table. One private field was
therefore enough to make every pg table and column type non-portable between
two installs.

config follows its base: ColumnBuilder#config is already @internal and the pg
override had not inherited the marker.

The other dialects declare the same foreignKeyConfigs field but are unaffected,
since only pg-core exposes toBuilder() in a public return type.

Flips PgTable and PgColumn to portable. NodePgDatabase stays recorded as a known
leak: its chain runs through the session, transaction and relational query
builder classes and does not converge to a contained change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qd99Mmmi1fZkKQiV3u1jXy
The view classes leaked two kinds of nominal member into the emitted .d.ts:

Symbol.for-keyed config properties ([PgViewConfig], [MySqlViewConfig] and the
materialized/dialect equivalents). A Symbol.for key is a distinct computed key
per declaration site, so copy A's PgView carries a property copy B's does not,
even though Symbol.for guarantees the runtime symbol is shared. Table already
marks every Symbol.for key it declares @internal, which is why the table and
column types were portable while the views were not; this applies the same
treatment.

Protected brand members ($MySqlViewBrand and friends), which exist only to keep
the view classes distinct at type level.

Flips PgView, MySqlView and SQLiteView to portable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qd99Mmmi1fZkKQiV3u1jXy
…tions

The dialect classes emitted their private query-building methods into the
published .d.ts as `private buildSelection;` and friends, which makes the class
nominal and so non-portable between two installs.

AggregatedField#table is the same bug one level down. The union inside
BuildRelationalQueryResult['selection'] names AggregatedField directly, so a
protected member there makes that class nominal, which makes the recursive
selection type non-portable, which propagates out through every dialect's
mapperGenerators. The diagnostic surfaces at the recursive type rather than at
the member, which makes it easy to mistake for a compiler limitation.

This does not yet flip the dialect rows: behind these members they come to rest
on EmptyFilter, whose unique symbol type is nominal per declaration site. That
is an API decision rather than a marker, and is made separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qd99Mmmi1fZkKQiV3u1jXy
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