Nakodo stores creator and business outreach campaigns in 25 Postgres tables. Fourteen columns across ten of those tables are jsonb, and every one of them is declared like this:
// src/db/schema.ts targeting: jsonb().$type<BusinessTargeting>(), brief: jsonb().$type<Brief>(), researchPages: jsonb().$type<ResearchPage[]>().notNull().default([]), outreach: jsonb().$type<OutreachSettings>(), $type<T>() is the reason the application code can write campaign.brief.idealCreator.contentStyle and have it type-check. It is worth knowing exactly how much work that method does. Here it is, in drizzle-orm 0.45.3:
// node_modules/drizzle-orm/column-builder.js $type() { return this; } That is the whole implementation. The method returns the builder it was called on, unchanged, and the generated SQL says so:
"brief" jsonb, "researchPages" jsonb DEFAULT '[]'::jsonb NOT NULL, So the type lives in TypeScript and nowhere else. Postgres knows the column holds valid JSON. It does not know the JSON is a brief. Nothing in the read path checks either.
Three ways to treat a column that checks nothing
Of the 14, one is validated on the way in, one is migrated on the way out, and the rest are trusted.
Validated on write, because a browser sends it. Business campaign targeting arrives from a form, so it goes through Zod before it reaches the column:
// src/lib/places/targeting.ts const targetingSchema = z.object({ types: z.array(typeSchema).min(1).max(MAX_TYPES), locations: z.array(locationSchema).min(1).max(MAX_LOCATIONS), independentOnly: z.boolean(), }); class TargetingError extends UserError {} The $type<BusinessTargeting>() on the column and the schema in that file are two descriptions of the same shape, kept in step by hand, and the one that runs is the schema.
Migrated on read, because the shape changed. The guesses inside a brief used to be plain sentences and are now { note, part } objects. The schema handles both with a union and a transform:
const storedGuesses = z.array( z .union([z.string(), z.object({ note: z.string(), part: z.enum(BRIEF_PARTS).nullable() })]) .transform((a): Assumption => (typeof a === "string" ? { note: a, part: null } : a)), ); That schema runs on saves. Reads do not go through it, and the comment in the file is explicit about why there is a hand-written twin:
// A brief's guesses, with the older plain-sentence ones given a null part. // Stored briefs are read straight from jsonb, so they aren't parsed first. export function readAssumptions(brief: Pick<Brief, "assumptions">): Assumption[] { return (brief.assumptions as (Assumption | string)[]).map((a) => (typeof a === "string" ? { note: a, part: null } : a)); } A cast plus a one-line reader is a cheaper migration than a backfill, and it is honest about what the old rows contain. It is also the only read-side check in the file, which is the part I would change.
Trusted: the other twelve, including a run summary, a reply classification, an offer for one creator, and the payload of a queued job, which is a discriminated map with one shape per job type. Two of the fourteen hold the analysis that decides who is worth writing to, and their field lists stay out of these posts, as they have in every earlier one.
The discipline a cast cannot enforce
Search the 14 payload types for a Date field and you find none. Every moment stored inside a jsonb value in this schema is a string:
proposalSavedAt?: string; // when the brand last saved the proposal (ISO); unset until it has This is not an aesthetic choice, and it is the clearest example of the cast's limits. $type<{ savedAt: Date }>() compiles. Writing a Date into it compiles. What arrives in the column is whatever JSON.stringify produces:
$ node -e 'console.log(JSON.stringify({d:new Date("2026-10-30T12:00:00Z"),u:undefined,n:null,m:new Map([[1,2]]),s:new Set([1])}))' {"d":"2026-10-30T12:00:00.000Z","n":null,"m":{},"s":{}} A Date becomes a string, a Map and a Set become empty objects, and undefined disappears. The type would then promise a Date on every read, forever, and the first savedAt.getTime() would throw in production rather than in the editor. So the rule in this schema is that jsonb holds JSON's own types and nothing else, and the dates carry (ISO) in a comment beside them.
The vanishing undefined is load-bearing elsewhere. A brief records "widening is allowed" as the absence of a field:
// Only when turned off: widening is on unless the brief says otherwise, // so an untouched brief carries nothing. A field left out here is dropped // from every save, which is how it would quietly turn itself back on. ...(raw.widen === false && { widen: false }), What Postgres does to the value regardless
The TypeScript type has no opinion about this part. Measured on a scratch Postgres 17.11:
-- 1 order and duplicate keys as_jsonb | as_json ------------------+--------------------- {"a": 3, "b": 1} | {"b":1,"a":2,"a":3} -- 2 numbers jsonb ---------------------------------- {"a": 1.0, "b": 1000, "c": 0.10} -- 3 equality jsonb_eq ---------- t -- 4 whitespace jsonb_text | json_len ------------+---------- {"a": 1} | 12 jsonb parses, so key order is not preserved, the last of a duplicated key wins, and insignificant whitespace is gone. Numbers are kept as numeric, so 1.0 and 0.10 come back with their scale and 1e3 comes back as 1000. Two values with the same keys in a different order compare equal, which json cannot do at all:
ERROR: operator does not exist: json = json That equality is why the schema uses jsonb everywhere and why a change-detection hash over a row's columns means something. And there are exactly two JSON values a jsonb column will not take, no matter what the TypeScript says:
select '{"a":"x\u0000y"}'::jsonb; ERROR: unsupported Unicode escape sequence DETAIL: \u0000 cannot be converted to text. select '{"a":"\ud800"}'::jsonb; ERROR: invalid input syntax for type json DETAIL: Unicode low surrogate must follow a high surrogate. A TypeScript string can hold both. "x\u0000y" is a perfectly good JavaScript string, and so is a lone surrogate from a truncated emoji. Anything that puts crawled page text, an email body or a provider's error message inside a jsonb value has to deal with that, and in this codebase the stripping happens before the write rather than being discovered at it.
The one column whose type claims nothing
metadata: jsonb().$type<Record<string, unknown>>().notNull().default({}), That is the audit log, where each of the 67 recorded actions carries a different set of extra facts. A single type for all of them would be a cast that is simply false, so it is not written, and every reader of that column narrows it where it is read. Thirteen columns describe their contents and one admits it cannot.
What I would change
- The two columns whose shape has changed deserve a read-side parse, not a hand-written reader beside a Zod schema that only runs on writes.
- There is not a single
CHECKconstraint on any of the 14. A bad write is detectable only when something reads it, which is the trade for a column with no shape in the database. - Writing
jsonb().$type<T>()reads like a declaration. It is a comment with type inference attached, and the schema file would be clearer if the three categories above were marked as such.
If you want to see what actually ends up stored, the public pages are the honest list: what is kept and for how long and how a campaign runs, with the plans and their limits on pricing.

