Skip to main content
ASoc
Tutorial

Supabase Generate Types: The Command, and When to Skip It

What supabase gen types typescript produces, and why one shipped commerce backend runs six tables and 271 lines of SQL with zero generated types.

The ASoc Team8 min read

supabase gen types typescript reads a live Postgres schema and prints a single Database type describing every table, view, enum and function — Row, Insert and Update shapes for each. You pass it to the Supabase client as a generic, and every query result and insert payload becomes typed.

That is the command. The more useful question is whether to commit its output, and this storefront answers no: it runs Supabase with six tables, eight migrations and zero generated types. The reasoning is worth more than the flag list, because it is the part the docs cannot tell you.

The command, in the three forms you will actually use

# Against the hosted project (needs network + the project ref)
npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > src/types/database.types.ts

# Against the local Docker stack
npx supabase gen types typescript --local > src/types/database.types.ts

# Other languages: go | swift | python
npx supabase gen types --lang=python --project-id "$PROJECT_REF" --schema public > database_types.py

Then the type becomes a generic on the client:

import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/types/database.types";

createBrowserClient<Database>(url, anonKey);

Three things about that command bite people, and all three are consequences of the same fact — it is a snapshot, not a derivation:

  • It needs a reachable database. There is no offline mode, and no way to generate from the .sql files in your migrations folder.
  • --project-id and --local can disagree. If a migration is applied locally and not remotely, the two outputs differ and nothing warns you.
  • The output is generated code you are then expected to commit, so it enters review, diffs, and merge conflicts like any other file.

What this codebase has instead

Here is the honest census, measured today against the repo behind this site:

Count
Tables in supabase/migrations/6
Migration files / lines of SQL8 / 271
Postgres functions / of those SECURITY DEFINER4 / 3
Generated Database types committed0
createClient<Database>() call sites0
Hand-written row interfaces3
Test files / tests / duration31 / 391 / 3.12s

The six tables are profiles, orders, entitlement_slots, download_events, download_deliveries and refund_requests — a real commerce schema with row-level security on all of it. The clients that read it are nine, thirty-one and fifteen lines long, and none of them carries a generic:

// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";

/** Browser Supabase client (anon key). Use in Client Components. */
export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  );
}

Instead of one generated type covering everything, each query declares the narrow shape it selected. All three of them look like this:

// src/lib/actions/entitlementsView.ts
interface SlotRow {
  kind: SlotKind;
  product_slug: string | null;
  framework: string | null;
  status: "active" | "revoked";
}

And the query that produces it selects exactly those four columns:

const { data, error } = await supabase
  .from("entitlement_slots")
  .select("kind, product_slug, framework, status")
  .eq("user_id", user.id)
  .eq("status", "active");

const slots: Slot[] = ((data ?? []) as SlotRow[]).map((row) => ({
  kind: row.kind,
  productSlug: row.product_slug,
  framework: row.framework,
  status: row.status,
}));

That .map() is the whole trick, and it is doing two jobs at once.

The seam: snake_case at the boundary, domain types inside

SlotRow is a description of a wire format. It is snake_case because Postgres is, it is nullable where the column is, and it exists for exactly as long as it takes to read the response. What the application actually reasons about is a different type, declared in a file that imports no database client at all:

// src/lib/entitlements.ts
export type SlotKind = "template_single" | "all_templates" | "all_access";
export interface Slot {
  kind: SlotKind;
  productSlug: string | null;
  framework: string | null;
  status: "active" | "revoked";
}

src/lib/entitlements.ts is where the question "may this buyer download this file?" is answered, as a pure function over Slot[]. It has no await, no Supabase import, and nothing to mock — which is why 391 tests run in three seconds with no container and no network. A generated Database type would not have changed that, but it would have made it tempting to skip the seam and pass Database["public"]["Tables"]["entitlement_slots"]["Row"] straight into the authorization logic. Then the function that decides who may download your paid files is typed by a code generator pointed at a database, and the thing you most want to unit-test needs a schema to compile.

Note also that kind is typed as SlotKind, the three-member union — not as string. Generated types will give you string here unless the column is a Postgres enum, because that is what the column is. The hand-written interface can be narrower than the schema, and in this case being narrower is the point: slotCovers switches exhaustively over those three cases, and TypeScript enforces the exhaustiveness.

Generated versus hand-written, by the axes that matter

supabase gen typesNarrow hand-written row types
Covers tables you never queryYes, all of themNo — only what you select
Catches a renamed columnOn the next regenerationOn the next regeneration, i.e. never — the cast hides it
Catches a select() / type mismatchYes, if you also type the selectNo
Needs a reachable database to buildTo generate, yesNever
Narrower than the column allowsNoYes (SlotKind, not string)
Keeps domain logic database-freeOnly if you add a mapping layer anywayBy construction
Review surfaceA large generated file in every schema PRFour lines beside the query
Drift riskOutput silently stales against the schemaThe cast silently lies about the schema

Read the last two rows together, because they are the real trade. Both approaches can drift. Generated types drift visibly — the file is in the diff, and the fix is one command. A hand-written cast drifts invisibly: as SlotRow[] is an assertion, so if someone renames product_slug the TypeScript build stays green and the mapped field becomes undefined at runtime.

This codebase accepts that risk for one specific reason: the schema is eight append-only migration files that have never renamed a column, and the three places that cast are three places. At thirty query sites and a schema still in motion, the arithmetic reverses and generated types win. That is a size-and-velocity judgement, not a principle.

When to generate, and when to commit the output

Generate when more than a handful of modules read the database directly, when your schema is still changing weekly, or when you have Insert/Update payloads — writes are where a generated type earns most, because a missing not null column is a runtime error that Insert turns into a compile error. Here, every write goes through a SECURITY DEFINER RPC with a fixed argument list instead, so there is no insert payload to type.

Commit the output if you generate it. The alternative — regenerating in CI — makes your build depend on a database being reachable, and on which database. If you do commit it, pin which target produced it: a file generated from --local while production has an extra column is the drift the Supabase docs' own troubleshooting mentions, and it is indistinguishable from a correct file by inspection.

What not to do is generate types and keep hand-rolled row interfaces for the same table. That is two sources of truth with no test holding them equal, and the hand-rolled one always wins the argument at runtime.

Troubleshooting

SymptomCauseFix
Cannot find project refNo linked project and no --project-idPass --project-id, or supabase link first
Command hangs or times out--local with the Docker stack downsupabase start, or switch to --project-id
Types are generated but every query is anyThe Database generic was never passed to createClientcreateServerClient<Database>(…)
A column exists in the DB but not in the typeGenerated against the other targetRegenerate deliberately; record which target
Enum column types as stringIt is a text column with a CHECK, not a Postgres enumMake it an enum, or narrow it by hand
Build is green, a field is undefined at runtimeAn as SomeRow[] assertion outlived a column renameReplace the cast with a validated parse, or generate types
Row has every column optionalGenerating against a view, not a tableViews are read-only; Row is all you get

Frequently asked questions

Is supabase gen types required to use Supabase with TypeScript? No. The client works untyped, and queries return any-shaped data you can narrow yourself. This storefront has shipped six tables, row-level security and a payment webhook that way. Generated types are a convenience with a real cost, not a prerequisite.

What is the difference between supabase gen types and supabase gen types typescript? typescript is the language argument; the newer form is --lang=typescript, and go, swift and python are also supported. supabase gen types typescript and supabase gen types --lang=typescript produce the same output.

Can I generate types without the Supabase CLI installed? The project dashboard can produce and download the same TypeScript for the public schema, which is enough if you only need it occasionally. There is no way to derive it from your .sql migration files — the command reads a live database, so something must be running.

Should the generated file be in .gitignore? No. Commit it. Ignoring it means your build either depends on a reachable database or compiles against stale types that happen to be in someone's working copy. If you want the regeneration enforced, do it in CI as a check that the committed file is unchanged, not as the step that creates it.

Does any of this change with row-level security? No, and that is worth saying explicitly: generated types describe the schema, not your policies. A Row type says the column exists, never that this caller may read it. The gate stays in the database — RLS policies and, here, a pure authorization function both queries call.

Templates that ship without a generated schema

Everything above is a backend decision, which is why the Next.js landing page templates and Tailwind landing page templates do not take a position on it — they render statically and leave the data layer to you, typed however your schema's velocity deserves.

Templates in this post

ASoc Folio, ASoc Forge and ASoc Frame are Next.js + Tailwind landing page templates with no database client wired in, so the first Supabase query you add is the one that decides whether you need generated types at all.

Keep reading

Tutorial8 min read

Supabase Google Auth in Next.js: One Server Action, Three Consoles

The Server Action, the shared PKCE callback and the three dashboard surfaces that actually break Google sign-in — including the trailing slash that cost us a redirect.

Read more
Tutorial8 min read

Supabase Local Dev: The Codebase That Never Runs supabase start

Eight migrations, six RLS policies and 357 tests in 3.3 seconds — with no Docker and no local stack. What that buys, and exactly where it stops working.

Read more