Skip to content

Make branded column types first class and strict - #20

Merged
Makisuo merged 1 commit into
mainfrom
feat/branded-columns
Oct 4, 2026
Merged

Makisuo merged 1 commit into
mainfrom
feat/branded-columns

Conversation

@Makisuo

@Makisuo Makisuo commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Why

Brands were only an annotation. Rows decoded to OrgId, but comparisons widened a branded column back to plain string, so a few obvious mistakes compiled:

  • $.org_id.eq(userId)
  • $.org_id.eq("anything")
  • joining an OrgId column to an unbranded one

A brand says a value is one kind of id and not another, and that has to hold where values are compared and written. That parity with drizzle's $type<OrgId>() matters for moving Maple's Postgres code off drizzle.

What changed

  • Strict brands (breaking). Widen keeps branded types. Comparisons (eq, in_, between, joins), insert/update values and INSERT ... SELECT now take only:

    • a value of the brand;
    • a column of the same brand;
    • a param declared with the type, CH.param.of(orgIdType, "orgId"). compile then requires an OrgId value for it.

    A plain string, another brand, param.string or an unbranded column is a type error. Literal unions still compare against their primitive.

  • brand(type, schema), exported from the root, /types and /postgres. It narrows any column type with an Effect schema while keeping the base type's SQL type and wire codec. PG.brand(PG.int8, Cents) still reads node-postgres's string form, which custom could not. It nests as nullable(brand(...)) and array(brand(...)), and S.pg.table writes the base type in the DDL.

  • SelectRowOf<typeof table> gives the whole decoded row with brands kept (drizzle's $inferSelect).

  • Checks run both ways. A row that fails the brand's checks is a decode error. A literal or param value that fails them is a QueryBuilderError from compile.

Migration

Replace $.OrgId.eq(CH.param.string("orgId")) with a param declared once next to the column type:

const orgId = T.custom("String", OrgId) // or T.brand(T.string, OrgId)
export const orgIdParam = CH.param.of(orgId, "orgId")
// $.OrgId.eq(orgIdParam), compiled with { orgId: OrgId }

Maple's ClickHouse queries have 847 param.string("orgId" | "traceId" | "spanId") uses across 51 files. The change is mechanical.

Testing

  • Type tests (src/ch/brand.test-d.ts): every forbidden case is a @ts-expect-error, including UserId for OrgId, param.string, plain strings in in_, an unbranded join column and wrong brands in insert/update. Every allowed form compiles.
  • PGlite tests (src/ch/brand.test.ts): the base codec is kept, invalid literals and params are refused at compile, and a branded insert/select round-trips with RETURNING. A row that breaks the brand fails to decode.
  • Coverage cases: brand is in the live coverage cases for both dialects: a branded UInt64 on ClickHouse and a branded int8 read from text on Postgres.
  • Full suite: bun run test passes (610 tests plus the doc citation, export catalog and doc-example checks; the new docs example compiles).
  • Live ClickHouse: the tests/ suite (283 tests) passes against a fresh clickhouse-server:26.2.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Brands were an annotation: rows carried OrgId, but comparisons widened a
branded column to plain string, so `$.org_id.eq(userId)` compiled. A brand
is the claim that a value is one kind of id and not another; that has to
hold where values are compared and written.

- Widen keeps branded types: comparisons, IN, insert/update values and
  INSERT ... SELECT take the brand (a value, a same-brand column, or a
  param.of(type, name)); plain strings, other brands, param.string and
  unbranded columns are type errors. Literal unions still widen.
- brand(type, schema): narrow any column type with an Effect schema while
  keeping its SQL type and wire codec (brand(PG.int8, Cents) still reads
  node-postgres's strings).
- SelectRowOf<typeof table>: the whole decoded row, brands kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 39 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c9674775-80c1-4442-bfed-8ceeae8e1e62
📥 Commits

Reviewing files that changed from the base of the PR and between acb4cd7 and f37c3ff.

📒 Files selected for processing (17)
  • CHANGELOG.md
  • docs/reference.md
  • docs/tables-and-types.md
  • src/ch/brand.test-d.ts
  • src/ch/brand.test.ts
  • src/ch/compile.test.ts
  • src/ch/expr.ts
  • src/ch/index.ts
  • src/ch/insert.test-d.ts
  • src/ch/insert.ts
  • src/ch/query.ts
  • src/ch/table.ts
  • src/ch/types.ts
  • src/pg/types.ts
  • src/types.ts
  • tests/dialect-cases.postgres.ts
  • tests/dialect-cases.ts
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Makisuo
Makisuo merged commit 080c8ec into main Oct 4, 2026
4 checks passed
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