Skip to content

Type System & Generated Types

A layered TypeScript type strategy in which database schema types are machine-generated from Supabase, and all domain types are derived from those generated types through a documented set of TypeScript utility patterns rather than being hand-authored.

The application treats generated types as the single source of truth for anything shape-related. Rather than declaring entity interfaces by hand, the codebase generates a TypeScript description of the database schema and then composes domain types out of it using standard TypeScript utility types.

Three families of generated types exist:

Family Generated by Output Purpose
Database schema types supabase gen types typescript --local src/types/supabase.ts Row / Insert / Update shapes for every table, view, function, and enum
Next.js route types next typegen (Next.js managed) Typed route params and route handler contracts
Cloudflare environment types wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts Typed bindings for the Cloudflare runtime environment

src/types/supabase.ts is committed to the repository and regenerated locally by developers after migrations. CI does not run db:gen; it runs only typegen (route and Cloudflare types), which does not require a local Supabase instance.

flowchart TD
    subgraph sg_External["External Systems"]
        DB[("Supabase Postgres")]
        Next["Next.js router"]
        CF["Cloudflare runtime"]
    end

    subgraph sg_Generation["Generation Layer (pnpm scripts)"]
        DbTypes["pnpm db:types<br/>supabase gen types typescript"]
        TypeGen["pnpm typegen<br/>next typegen + wrangler types"]
    end

    subgraph sg_Generated["Generated Artifacts"]
        SupabaseTS["src/types/supabase.ts<br/>Database / Tables / TablesInsert / TablesUpdate"]
        RouteTypes["Next.js route types"]
        CFEnv["cloudflare-env.d.ts<br/>CloudflareEnv interface"]
    end

    subgraph sg_Domain["Domain Type Layer (src/types/*.ts)"]
        Projects["projects.ts"]
        Posts["posts.ts"]
        Articles["articles.ts"]
        Others["profiles · comments · organizations · ..."]
    end

    subgraph sg_Consumers["Consumers"]
        SC["Server Components / Actions"]
        API["API Routes"]
        CC["Client Components"]
    end

    DB --> DbTypes
    Next --> TypeGen
    CF --> TypeGen
    DbTypes --> SupabaseTS
    TypeGen --> RouteTypes
    TypeGen --> CFEnv
    SupabaseTS --> Projects
    SupabaseTS --> Posts
    SupabaseTS --> Articles
    SupabaseTS --> Others
    Projects --> SC
    Posts --> SC
    Articles --> API
    Others --> CC

The primary generated artifact is src/types/supabase.ts, about 6,000 lines. Its entry point is the Database type, a namespace-per-schema structure. Each schema (public, and the Supabase-managed graphql_public) exposes six parallel maps: Tables, Views, Functions, Enums, CompositeTypes, and — within Tables — per-table Row, Insert, Update, and Relationships.

The three-shape pattern repeats for every table: Row reflects the persisted shape (nullable columns are T | null), Insert makes columns with database defaults optional (?), and Update makes every column optional. This is the mechanism that lets mutation code use TablesInsert<"projects"> and get exactly the right optionality without any maintenance.

The committed types can lag behind migrations. For example, a migration that drops the pg_graphql extension leaves the graphql_public namespace — including its graphql function — present in supabase.ts until pnpm db:gen is re-run against the local schema. Stale types produce no compiler error; tsc validates against the generated file, which is internally consistent. Runtime query failures against removed columns or functions are the observable symptom.

Generation is driven by two pnpm scripts (package.json):

Command Composed of Output
pnpm typegen next typegen then wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts Next.js route types + cloudflare-env.d.ts
pnpm db:types supabase gen types typescript --local redirected into src/types/supabase.ts, then prettier --write Formatted src/types/supabase.ts
pnpm db:gen pnpm db:schema then pnpm db:types Schema docs + TypeScript DB types

Three design details stand out:

  1. Chained with &&. A failure in the first step aborts the second, preventing stale artifacts from masking the failure.
  2. Prettier is part of the pipeline. db:types pipes generator output through prettier --write. Without this step, regeneration diffs would include whitespace noise, burying real schema changes in code review.
  3. --local targets the local stack. supabase gen types typescript --local reads the schema from the locally running Supabase instance, making type generation deterministic and offline-capable.

CI runs install → typegen → lint → build. db:gen is a developer step, not a CI step; src/types/supabase.ts is committed.

sequenceDiagram
    participant CI as CI Runner
    participant Pnpm as pnpm
    participant FS as Filesystem
    participant Build as Next.js Build

    CI->>Pnpm: install dependencies (--frozen-lockfile)
    CI->>Pnpm: run typegen
    Pnpm->>FS: write route types + cloudflare-env.d.ts
    CI->>Pnpm: run lint
    CI->>Build: ci:build with real env vars/secrets
    Build-->>CI: compiled artifact

Installation precedes generation because generation tooling (wrangler, next) comes from dependencies. Lint runs after typegen because lint and type checks operate against the generated artifacts.

All hand-written types live under src/types/, one file per domain concept, re-exported through the src/types/index.ts barrel. Domain types are never declared inside component files.

Four composition patterns build domain types from generated primitives:

type Project = Tables<"projects">; // full row
type Slug = Pick<Tables<"resource_subcategories">, "id" | "name" | "slug">; // projection
type Comment = Tables<"post_comments"> & { author: UserProfileForJoin }; // join
type Article = Omit<Tables<"articles">, "article_type"> & {
article_type: { id: number; type: string } | null; // FK override
};
const insert: TablesInsert<"projects"> = { title, slug, created_by: user.id }; // mutation
Pattern Utility Why it is used
Full row Tables<"projects"> Entity identity — canonical shape with zero mapping code
Projection Pick<Tables<...>, "id" | "name" | "slug"> Lists and nested summaries need only a subset; Pick guarantees the subset stays valid
Join Tables<"post_comments"> & { author: UserProfileForJoin } PostgREST joins return extra embedded objects; intersection composes them without redeclaring base columns
FK override Omit<Tables<"articles">, "article_type"> & { article_type: ... } A FK column is a scalar in Row, but an embedded object at runtime; Omit + re-add replaces exactly one field
Mutation TablesInsert<"projects"> Insert shapes carry correct optionality from the generated Insert variant

The FK override pattern is the most instructive: a foreign-key column in the generated Row type is a scalar (an id), but when a query embeds the related row, the runtime value is an object. Omit<> removes the scalar field and the intersection adds the object shape. This resolves the mismatch explicitly rather than with as casts.

flowchart TD
    Gen["Tables&lt;'x'&gt; (generated)"] --> Full["Tables&lt;'projects'&gt;<br/>full row"]
    Gen --> Pick["Pick&lt;Tables&lt;'resource_subcategories'&gt;, 'id'|'name'|'slug'&gt;<br/>projection"]
    Gen --> Omit["Omit&lt;Tables&lt;'articles'&gt;, 'article_type'&gt;<br/>strip one field"]

    Omit --> Inter["&amp; { article_type: { id; type } | null }<br/>FK override"]
    Gen --> Inter2["&amp; { author: UserProfileForJoin }<br/>join"]

    Full --> Use1["Entity types"]
    Pick --> Use2["List items / selects"]
    Inter --> Use3["Embedded-relation entities"]
    Inter2 --> Use3

    Ins["TablesInsert&lt;'projects'&gt; (generated)"] --> Use4["Server Actions / API inserts"]
    Upd["TablesUpdate&lt;'x'&gt; (generated)"] --> Use5["Partial updates"]

Generated types are always imported with import type so the ~6,000-line supabase.ts is erased at compile time and never enters a runtime bundle:

import type { Tables, TablesInsert, TablesUpdate } from "@/types/supabase";

Most domain files are pure Pick/Omit compositions over generated tables. A few hold hand-rolled form or result models that cannot be derived: ArticleFormData (the article editor state, too far from any table shape to derive), and SearchResult<TMeta> / AttachSearchResult (normalised rows returned by the entity search routes). Two files contain hand-rolled duplicates of existing generated shapes — candidates for cleanup: IndigenousMacroRegion redeclares the indigenous_macro_regions row field-by-field even though Tables<"indigenous_macro_regions"> exists, and ArticleAuthor extends Tables<"article_authors"> re-lists every column redundantly.

MembershipResult in src/types/memberships.ts is a discriminated union on the literal type field with variants "organization", "pod", and "project". The "pod" variant exists for the placeholder table — pods are a roadmap feature with no UI.

Stale generated types after migrations. Because src/types/supabase.ts is committed and regenerated locally, a migration can land while the committed types remain at the previous schema. The project addresses this by convention: db:gen is documented as “(run after migrations)”. Stale types produce no compiler error — tsc validates against the generated file, which is internally consistent. Runtime query failures against removed columns or functions are the observable symptom. The graphql_public namespace in the committed types is a live example: a migration drops pg_graphql, but the namespace persists until the next db:gen.

Optionality mistakes on writes. Using a Row type for a write forces the caller to supply defaulted columns unnecessarily; using Insert for reads permits treating an optional field as always present. The correct pairing (Tables<> for reads, TablesInsert/TablesUpdate for writes) is a per-call-site discipline with no automated enforcement beyond types.

FK view/embed mismatch. Embedded relations return objects where the generated Row declares scalars. The FK-override pattern (Omit<> & {}) is the sanctioned fix; casting with as disables checking for the entire object.

Generated types are disposable. Never hand-edit src/types/supabase.ts. Any manual patch is destroyed at the next pnpm db:gen. Structural corrections belong in migrations (which change the generated output) or in the domain files (which derive from it).

  • src/types/supabase.ts is about 6,000 lines and should always be imported with import type so it is erased at compile time and never enters a runtime bundle.
  • Prettier as a pipeline stage keeps regeneration diffs reviewable. Without it, every schema change would reformat the entire file, burying real changes in whitespace noise.
  • pnpm typegen does not require a local Supabase instance, so it runs in CI without database credentials. pnpm db:gen requires a running local Supabase and is a developer workflow.
  • Adding a domain type file: create a new module under src/types/ (e.g. widgets.ts) and derive from Tables<"widgets">; export via src/types/index.ts. Do not add types inside component files.
  • Adding a Pick/Omit variant: add a derived alias in the relevant src/types/*.ts file rather than an inline structural type at the call site, keeping derivations discoverable and reusable.
  • New Cloudflare bindings: regenerate cloudflare-env.d.ts with wrangler types --env-interface CloudflareEnv; no hand-editing of CloudflareEnv is required.