zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Build an application

Array columns and empty defaultsSupported

A json column holds arrays and objects, and its shape is part of the declaration. Two things to get right: where the default value lives, and what HasDefault does and does not say.

Declaring one#

import type { HasDefault, PrimaryKey, Serial, Sql, Table } from '@zmdb/core/tags';

interface Preferences {
  theme: 'light' | 'dark';
  digest: boolean;
}

export interface User extends Table<'users'> {
  id: number & Sql<'integer'> & Serial & PrimaryKey;
  email: string & Sql<'text'>;
  tags: string[] & Sql<'json'> & HasDefault;
  prefs: Preferences & Sql<'json'> & HasDefault;
}

tags and prefs are optional in CreateDTO because they say HasDefault, and fully typed in Entityrow.prefs.theme is 'light' | 'dark', not unknown.

The shape is on the property, so there is nothing to remember to supply. json<T>() used to take its payload as a type argument you could simply omit, and a bare json() gave you unknown and a cast at every read. That failure mode is gone: a json column's type is the type you wrote.

Where the default value lives#

Not in the schema. HasDefault says the column _has_ a default, not _which one_, and it cannot say which one: a default is a runtime value and no type holds one.

So write it in the migration, where the DDL is written anyway:

ALTER TABLE "users" ALTER COLUMN "tags" SET DEFAULT '[]'::jsonb;
ALTER TABLE "users" ALTER COLUMN "prefs" SET DEFAULT '{"theme":"dark","digest":true}'::jsonb;

The database applies it, which means it also applies to rows inserted by anything else — a migration, a psql session, another service. That is the behaviour you want, and it is why this is the right home for the value rather than a consolation prize.

⚠️ Warning

A default only fills a column that is omitted. Passing undefined explicitly is still an omission, but passing null is not — it stores null, and you will read null from a column your type says is string[].

The shared-reference trap is gone#

The old defaultTo([]) put a real array into the schema object, and anything that mutated a default read back out of the schema mutated it for every row that had used it — the useState([]) versus a module-level array problem, one layer down.

There is nothing to share now. HasDefault is a phantom symbol slot that disappears at compile time, so the schema carries no array to alias. Older defineSchema code can still contain mutable defaults; the codemod reports each one it removes.

Reading and writing#

The driver returns JSON columns as parsed values in the Postgres family (json/jsonb) and as strings in the MySQL family, SQLite and SQL Server. That difference is the driver's, not zmdb's:

const raw = row.tags;
const tags = typeof raw === 'string' ? assert<string[]>(JSON.parse(raw)) : raw;

assert rather than a cast, because the column's contents are only as good as whatever last wrote them — including a hand-run UPDATE from two years ago. See Serialization.

Appending#

The closed set() expression vocabulary has no JSON or array-append variant, so read-modify-write still races here exactly as it does for counters outside the supported inc() form. Postgres can append atomically:

await driver.execute({
  effects: { operation: 'UPDATE', requiresPrimary: true, returnsRows: false },
  text: `UPDATE "users" SET "tags" = "tags" || $1::jsonb WHERE "id" = $2`,
  parameters: [JSON.stringify([tag]), id],
});

Deduplicating requires more SQL, at which point a join table is the better design.

A real array column instead#

Postgres has native text[], and SqlType does not include it — the ten types are serial integer bigint numeric text varchar boolean timestamp json jsonEnum. So a genuine array column needs a hand-written migration and a Sql<'json'> declaration that lies slightly about the storage type. It works for reads and writes; DDL generation for that table then has to be yours.

Usually json is fine. Where you need ANY, @> or a GIN index over array elements, take the migration.

Prefer a join table when you query the contents#

import type { Length, PrimaryKey, References, Sql, Table } from '@zmdb/core/tags';

export interface UserTag extends Table<'user_tags'> {
  userId: number & Sql<'integer'> & References<'users.id'> & PrimaryKey;
  tag: string & Sql<'varchar'> & Length<64> & PrimaryKey;
}

If you ever filter, group or count by tag, this is the right model — it indexes, it joins, and it does not need JSON operators. Two PrimaryKey tags say composite, and generated DDL emits one ordered table-level key, so the same tag cannot be attached twice for one user. See Composite Keys. Keep json for opaque blobs you read whole, like prefs.

Enum-valued columns#

status: ('draft' | 'published' | 'archived') & HasDefault;

A literal union, and that is all. There is no jsonEnum tag and no as const to forget: the old jsonEnum(['draft', 'published'] as const) widened to string[] without the as const, silently giving you string instead of the union and losing the narrowing that was the entire point. A union type cannot widen by accident.

---

See also: Column Types · JSON Properties · Timestamp defaults