Skip to content

Reject unknown values in the table extra config array - #6141

Open
Ash20pk wants to merge 1 commit into
drizzle-team:betafrom
Ash20pk:fix/extra-config-nominal-builders
Open

Reject unknown values in the table extra config array#6141
Ash20pk wants to merge 1 commit into
drizzle-team:betafrom
Ash20pk:fix/extra-config-nominal-builders

Conversation

@Ash20pk

@Ash20pk Ash20pk commented Aug 18, 2026

Copy link
Copy Markdown

Fixes #6140. Targeting beta since the report is against 1.0.0-rc.4.

The problem

Two things compounding each other.

The published types accept anything. The build strips @internal members from the emitted .d.ts (stripInternal: true). PrimaryKeyBuilder had every instance member marked internal — columns, name, build() — and constructors and statics don't contribute to an instance type. So the published type is structurally empty, and an empty object type accepts any object. Because it's one member of the extra-config union, the whole array became permissive:

const pk: PrimaryKeyBuilder = { totallyBogus: 42 }; // compiled clean

getTableConfig dropped what it didn't recognise. The if / else if chain over is(builder, …) had no final else, so anything unrecognised was skipped in silence. generate and push both go through getTableConfig, which is why a declared index simply never appeared in a migration.

The combination is what makes it nasty: the schema file, the migrations and the database all agree the index isn't there, so no drift check can surface it. And [{ key: builder }] is exactly what you get by mechanically wrapping the old deprecated object form in an array.

Scope

PrimaryKeyBuilder was structurally empty in pg, cockroach, mysql, mssql and singlestore. sqlite-core was already correct — it carries declare _: { brand: 'SQLitePrimaryKeyBuilder' }, which is the pattern this PR applies everywhere else.

MySqlUniqueConstraintBuilder had the same problem (its name is @internal rather than private, unlike the other dialects').

The issue also names AnyIndexBuilder, but that one is fine — it's an interface with a build() method and rejects plain objects.

The fix

  1. Nominal builders. declare _: { brand: '…' } on the five PrimaryKeyBuilders and on MySqlUniqueConstraintBuilder, matching sqlite's existing precedent. Declaration-only, so no runtime emit and no behaviour change.

  2. Throw instead of skip. A final else in all six getTableConfig implementations, via a shared throwUnknownExtraConfigValue helper in table.utils.ts. It names the table and, for objects, points at the likely mistake:

    Invalid extra config value for table "bad": expected an index or constraint builder, but received a plain object with keys "myIndex". Builders must be returned as elements of the array, not wrapped in an object: (t) => [index("name").on(t.id)], not (t) => [{ myIndex: index("name").on(t.id) }].

  3. A latent mysql bug this uncovered. Branding MySqlUniqueConstraintBuilder broke type-tests/mysql/tables.ts, which revealed that MySqlTableExtraConfigValue referenced the builder without a type argument — defaulting TName to undefined, so unique('some_name').on(…) never actually matched it. It had been matching the empty PrimaryKeyBuilder instead. The union now says UniqueConstraintBuilder<string | undefined>.

Tests

drizzle-orm/tests/table-extra-config.test.ts, 30 tests in two halves.

Runtime, parameterised over all six dialects: the array form still builds the index, the wrapped-in-an-object form throws, a non-builder value throws.

Declaration emit. This half needed care. Type tests here compile against src, where the internal members are still visible — so a @ts-expect-error test would have passed even with the bug present. Instead it runs ts.transpileDeclaration with stripInternal over the real sources and asserts each builder still has at least one instance member in the emitted declaration. That checks the artifact users actually consume, which is where the bug lived.

Reverting only src/ fails 18 of the 30, so it's a genuine regression test.

Verification

  • 30/30 pass with the fix; 18 fail without it.
  • Full drizzle-orm suite: 994 pass, 3 fail — sql-builder and two casing files, which fail identically on unmodified beta. Pre-existing and unrelated.
  • tsc -p type-tests/tsconfig.json exits 0.
  • oxlint --max-warnings=0 and dprint clean.
  • Built the package and ran the issue's exact repro against dist/: good compiles, bad and worse are now compile errors, and getTableConfig(bad) throws the message above.

One thing worth a maintainer's opinion

Throwing means an undefined or false element in the array — from a conditional, say — now errors where it used to be skipped. TypeScript already rejected that shape, so it's consistent, but it is the sharp edge of failing loudly. Happy to downgrade to a warning if you'd rather not have that in a 1.0.

I left changelogs/drizzle-orm/ alone since those look release-scoped; glad to add an entry wherever you want it.

The extra config callback is typed to return builders, but `stripInternal`
removes every `@internal` member from the published `.d.ts`, and
`PrimaryKeyBuilder` had nothing else on it. An empty object type accepts any
object, so the union member made the whole array permissive: wrapping a builder
in an object, or returning something that isn't builder-shaped at all,
typechecked cleanly. `getTableConfig` then walked the array matching builders by
entity kind and skipped everything it didn't recognise, so the declared index or
constraint never reached the database and no drift check could surface it -- the
schema, the migrations and the database all agreed it wasn't there.

Give the affected builders a declared brand so they stay nominal after
declaration emit, and throw from `getTableConfig` on anything that isn't a
builder. `generate` and `push` go through `getTableConfig`, so they now fail
loudly instead of dropping the entry.

`MySqlUniqueConstraintBuilder` needed the same treatment, and widening it
exposed that `MySqlTableExtraConfigValue` referenced the builder without a type
argument, defaulting `TName` to `undefined`. `unique('name').on(...)` had been
matching the empty `PrimaryKeyBuilder` instead; the union now takes
`string | undefined`.

The nominality test asserts against the real declaration emit rather than the
source, since the source keeps its internal members and would not catch a
regression.

Fixes drizzle-team#6140
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.

1 participant