# Refining schema types

Your generated schema knows a column is `json()` or `string()`, but not the shape inside — the element type of a JSON blob, or a status column that's really a literal union. `refineTable` + `refineSchema` narrow those TS types once, in a hand-written module, and survive every regeneration of `schema.gen.ts`.

Rindle's [schema is generated from SQL](/docs/schema): a `TEXT` column becomes `string()`, a `JSON`
column becomes `json()`. That recovers the column's **kind**, but SQL can't carry a refinement *within*
a kind — the element type of a JSON blob (`json<Meta>()`), or a `status TEXT` column that's really a
`"todo" | "doing" | "done"` union. Those you layer on in TypeScript.

Do it with `refineTable` and `refineSchema` — **not** by editing `schema.gen.ts`, which is a
wholesale-regenerated artifact. Your refinements live in a small hand-written module and are reapplied
automatically every time you regenerate.

## Narrow the columns, swap in the table

`schema.gen.ts` exports each table def *and* the assembled schema. Import both into a hand-written
module, re-type the columns you care about with `refineTable`, then swap the refined defs into the
schema with `refineSchema`:

```ts
// schema.ts — hand-written, sits in front of the generated file
import { refineSchema, refineTable, json, string } from "@rindle/client";
import { schema as schemaGen, issue as issueGen } from "./schema.gen.ts";

export type Label = "bug" | "feature" | "chore";
export interface Meta { spent: number; estimate: number }

// re-type the generated `issue` columns within their kind:
export const issue = refineTable(issueGen, {
  status: string<"todo" | "doing" | "done">(),   // string() → literal union
  labels: json<Label[]>(),                        // json()   → typed element
  meta: json<Meta>(),                             // json()   → typed object
});

// swap the refined def into the schema so query roots and result rows narrow too:
export const schema = refineSchema(schemaGen, { tables: [issue] });
```

Now everything downstream is narrowed: `store.query.issue` conditions, `where`/`orderBy` on `status`,
and the rows you read back (`labels` is `Label[]`, not `unknown[]`). Build your query builder and named
queries off *this* `schema`, not the generated one.

## Why not just cast in the generated file?

Because the generated file is yours to *regenerate*, not to edit — re-run `rindle schema gen` after any
migration and hand edits are gone. `refineTable`/`refineSchema` keep the refinement in a separate module
that imports the generated one, so a regen never disturbs it. The generated header even points here.

They're also **runtime-validated identities**. A refinement may only *narrow the TS type* within a
column's kind — it can never change the kind, the primary key, or the locality. `refineTable` rejects
`string()` on a `json` column; `refineSchema` rejects a def that doesn't match the schema's table of
that name. So the def still matches the daemon's wire schema exactly — you get sharper types with zero
runtime risk of drift.

Composes with [`extendSchema`](/docs/local-only-tables) in either order: refine your generated tables,
extend with local-only ones, hand the result to `createRindleClient`.

## In the wild — Strut

[Strut](https://github.com/tantaman/strut) imports its generated tables with a `Gen` alias and refines
the columns SQL couldn't carry — a component's `type` discriminator and `props` blob, a deck's
`visibility`, a collaborator's `role`:

```ts
// shared/app-def.ts
import { json, refineSchema, refineTable, string /* … */ } from "@rindle/client";
import { component as componentGen, deck as deckGen, deck_share as deckShareGen,
         schema as schemaGen, slide as slideGen } from "./schema.ts";

export const component = refineTable(componentGen, {
  type: string<ComponentType>(),      // string() → the shape discriminator union
  props: json<ComponentProps>(),      // json()   → the typed props object
});
export const deck = refineTable(deckGen, {
  visibility: string<Visibility>(),   // 'private' | 'public-read'
});
export const deck_share = refineTable(deckShareGen, {
  role: string<CollaboratorRole>(),   // 'editor' | 'viewer'
});

export const schema = refineSchema(schemaGen, {
  tables: [component, deck, deck_share, slide],
});
```

*[`shared/app-def.ts` L54–70](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/shared/app-def.ts#L54-L70) · Strut*

Every query builder, fragment, and `Row<typeof …>` in Strut is built off that refined `schema`, so the
typed props object and the enums flow through the whole app — while `schema.ts` stays a pure, generated
file that's overwritten on every migration.

## See also

- [Schema & migrations](/docs/schema#column-types-arbitrary-type-names) — how SQL declared types map to
  column kinds, and what a refinement adds on top.
- [Compose the UI with fragments](/docs/fragments) — the query layer that reads through the refined
  types.

---

[View this page on Rindle](https://rindle.sh/docs/refining-schema-types)
