drizzle-team / drizzle-team/drizzle-orm

[FEATURE]: `effect-schema`: surface `customType`'s domain type to `createSelectSchema` (and `createInsert/UpdateSchema`)

Open
#5,848 0 comments 1 reaction 0 assignees View on GitHub
enhancement
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.