drizzle-team / drizzle-team/drizzle-orm
[FEATURE]: `effect-schema`: surface `customType`'s domain type to `createSelectSchema` (and `createInsert/UpdateSchema`)
- Dominant language
- TypeScript
- Stars
- 35.8k
- Forks
- 1.6k
- Avg merge
- 2d 7h
- Merged PRs (30d)
- 4
Description
### Feature hasn't been suggested before.
- [x] I have verified this feature I'm about to request hasn't been suggested before.
### Describe the enhancement you want to request
### Summary
`createSelectSchema` (and the matching `createInsertSchema` / `createUpdateSchema`) from `drizzle-orm/effect-schema` collapses every `customType` column to `Schema.Any`, even though the customType author has supplied the exact TypeScript domain type via the `data` slot. The downstream consumer ends up writing per-column refines for every customType column at every call site — and the schema is silently lossy until they do.
This makes `customType` the right primitive for domain-typed columns (Temporal classes, branded IDs, structural enums, JSON schemas) and then deletes that primitive's benefit at the wire boundary. We'd like Drizzle to either (a) thread the `data` type through to the generated schema by default, or (b) give the customType author a first-class hook to register the Effect Schema that should be used on its behalf.
### Repro
```ts
import { customType } from "drizzle-orm/pg-core";
import { Temporal } from "@js-temporal/polyfill";
const instantType = customType<{ data: Temporal.Instant; driverData: string }>({
dataType: () => "timestamptz",
toDriver: (v) => v.toString(),
fromDriver: (s) => Temporal.Instant.from(s),
});
export const auditTable = pgTable("audit", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: instantType("created_at").notNull(),
});
```
```ts
import { createSelectSchema } from "drizzle-orm/effect-schema";
import { Schema } from "effect";
const Row = createSelectSchema(auditTable);
type RowOut = Schema.Schema.Type;
// ^? { id: string; createdAt: any } ← should be Temporal.Instant
```
`createdAt` lands as `Schema.Any` even though the customType declared `data: Temporal.Instant`. The TS type at the call site is therefore `any`, and decode is a no-op pass-through (raw `string` flows past the boundary unchanged).
### Why this is painful
In a codebase with N tables and roughly four Temporal column types (`Instant`, `PlainDate`, `PlainTime`, `ZonedDateTime`-base), every router file ends up with this shape:
```ts
const JobRow = createSelectSchema(jobTable, {
...idOverrides(jobTable), // our own helper for branded IDs
createdAt: InstantSchema,
updatedAt: InstantSchema,
deletedAt: Schema.NullOr(InstantSchema),
openDate: Schema.NullOr(PlainDateSchema),
closeDate: Schema.NullOr(PlainDateSchema),
});
```
We just hit ~106 such call sites in our app. Every one of them is hand-listing what is already encoded in the schema definition — the Temporal column helper *already* knows it's an `instant`. The boilerplate has three problems:
1. **Easy to forget.** If you add a new Temporal column to a table and don't visit every router that selects it, the schema silently downgrades to `Schema.Any` and the wire boundary stops decoding — Temporal values arrive in the client as raw ISO strings (or worse, get `JSON.stringify`-ed into `[object Object]`).
2. **No type-level help.** Because the override is collapsed to `Schema.Any`, TypeScript can't tell you which column needs which codec. You only notice the drift when something at runtime breaks.
3. **Couples routers to schema choice.** Today every router has to know that "`createdAt` on this table is `Temporal.Instant`" — a fact already in the table definition.
We fixed it locally by tagging each `customType` builder in a `WeakMap` at construction and providing two helpers (`temporalOverrides(table)` + a unified `rowOverrides(table)`) that walk `getTableColumns(table)`, look up the tag, and emit the right Effect Schema per column. After the fix, every site reduces to:
```ts
const JobRow = createSelectSchema(jobTable, rowOverrides(jobTable));
```
The mechanism works, but every customType-using codebase shouldn't have to rebuild it. The right home for it is in Drizzle.
### Constraints we ran into
Drizzle's public surface today doesn't expose `customType` columns to the effect-schema generator in a way that lets it pick the right Effect Schema:
- `column.dataType === "custom"` and `column.columnType === "PgCustomColumn"` for every customType — indistinguishable.
- `column.getSQLType()` returns the DDL string (`"timestamptz"`, `"date"`, `"time"`, `"jsonb"`, …). Workable for some cases, but couples the override emitter to SQL syntax and silently mis-classifies any future customType whose DDL happens to overlap (two Temporal types both emit `"timestamptz"`).
- The customType `data` type lives only in TypeScript — `customType<{ data: T; driverData: D }>` — so it's available to type-level inference but not to the runtime that builds the Effect Schema.
The first internal type that gates this is `BuildRefineField` in `drizzle-orm/effect-schema/schema.types.internal.d.ts`:
```ts
type BuildRefineField = T extends Schema$1.Any
? ((schema: T) => Schema$1.Any) | Schema$1.Any
: never;
```
…which wraps `GetEffectSchemaType` — and `GetEffectSchemaType` is where the `customType` branch falls back to `Schema.Any` (`drizzle-orm/effect-schema/column.types.d.ts`).
### Proposed shapes (any of these would unblock us)
I think any of these would work; (A) is the most ambitious, (D) is the smallest. Happy to send a PR for whichever the maintainers prefer.
**A. Auto-derive from the `data` type parameter.**
Change `GetEffectSchemaType` so that a customType's `data` type maps to a default Effect Schema by structural inference (e.g. `Temporal.Instant` → `Schema.Schema`, `string` with a `& Brand<...>` → branded string schema, etc.). Requires a runtime registry on the customType return value because the `data` type is erased at runtime — see (B).
**B. New `effectSchema` option on `customType`.**
Let the customType author register the codec at definition time. This is the smallest change that makes the issue go away for every existing customType-using codebase:
```ts
const instantType = customType<{ data: Temporal.Instant; driverData: string }>({
dataType: () => "timestamptz",
toDriver: (v) => v.toString(),
fromDriver: (s) => Temporal.Instant.from(s),
effectSchema: InstantSchema, // ← new
});
```
Then `createSelectSchema` reads `column._.effectSchema` (or similar) inside `GetEffectSchemaType`'s customType branch and uses it instead of falling back to `Schema.Any`. The `notNull` / nullability handling at `HandleRefinement` already wraps with `Schema.NullOr` correctly — no change needed there.
**C. Symbol-keyed side-band registry.**
Same idea as (B) but a separate `tagCustomTypeSchema(col, schema)` helper for codebases that want to keep schema definitions Effect-Schema-free (avoids dragging `effect` into `drizzle-orm/pg-core`'s peer deps). We use exactly this pattern in our brand-ID registry — `WeakMap` keyed by the column builder, propagated to the built column inside `pgTable`. Happy to share the prior art.
**D. Public hook so callers can supply a `customType → Schema` lookup once.**
If touching the customType signature is too invasive, expose a one-time-config hook:
```ts
import { configureEffectSchema } from "drizzle-orm/effect-schema";
configureEffectSchema({
customTypeMap: (col) => /* return Schema or undefined */,
});
```
This is the worst from an ergonomics standpoint — global config is ugly — but it does close the same gap with the smallest API surface change.
### Migration concerns
The fix needs to be opt-in OR backwards-compatible with the existing `Schema.Any` fallback. Today a lot of code is implicitly relying on `Schema.Any` (no decode) for customType columns — it would be surprising for `pnpm update drizzle-orm` to start decoding values that previously passed through untouched. Option (B) is naturally opt-in (you only get the new behavior if you supply `effectSchema`). Option (A) would need a major-version bump.
### Related context
- We ship Temporal-aware columns (`instant`, `plainDate`, `plainTime`, `zonedDateTime`) — all `customType` with distinct `data` types but all collapsing to `Schema.Any` under the current generator.
- Effect Schema's `Schema.NullOr` semantics work fine — the issue is purely in the base codec selection.
- Same gap exists in `createInsertSchema` and `createUpdateSchema` — fix should apply uniformly.
Happy to PR (B) if it's the preferred direction. Thanks for the great library.
Contributor guide
Assessment
This issue has not been assessed yet.