Skip to content

feat(typegen): add bigint_as option for int8/numeric TypeScript generation#1083

Open
Maliik-B wants to merge 1 commit into
supabase:masterfrom
Maliik-B:fix/bigint-as-flag
Open

feat(typegen): add bigint_as option for int8/numeric TypeScript generation#1083
Maliik-B wants to merge 1 commit into
supabase:masterfrom
Maliik-B:fix/bigint-as-flag

Conversation

@Maliik-B

Copy link
Copy Markdown

Addresses #1078.

Problem

int8 and numeric are generated as TypeScript number, but values past Number.MAX_SAFE_INTEGER (2^53) are lossy once round-tripped through JSON. Consumers using snowflake/Instagram-style sharded IDs or xxhash64 ETL output hit this in production: a row fetched by id gets a corrupted id back, and /items/<id> 404s.

Approach

Adds an opt-in bigint_as option for the TypeScript generator with three values, defaulting to number so existing consumers are unaffected:

  • number (default): current behavior, fully back-compatible.
  • string: emit int8/numeric as string.
  • bigint: emit them as the native bigint type.

Surfaced two ways, matching the existing generator-option conventions:

  • Env var PG_META_GENERATE_TYPES_BIGINT_AS (standalone generation path), alongside PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS, _DEFAULT_SCHEMA, etc.
  • Query param ?bigint_as= on GET /generators/typescript, alongside detect_one_to_one_relationships and postgrest_version.

The mapping is scoped exactly as mandarini pointed out on the issue: only int8 and numeric are split out of the number branch. int2, int4, float4, and float8 all fit safely in a JS number and are left untouched.

On the wire format

mandarini's note that a pure type-level change is not sufficient on its own is the reason this is opt-in and defaults to number. JSON has no BigInt, and PostgREST returns int8 as a JSON number by default, so the data is already lossy on the wire before types ever enter the picture. bigint_as=string / bigint is meant for consumers who have already arranged a string/bigint wire format (PostgREST-level casting, or a JSON reviver on the client). It makes the generated types honest about a wire format the caller has opted into, rather than silently changing anyone's behavior.

Scope

TypeScript template only. The sibling templates (go.ts, python.ts, swift.ts) that mandarini flagged as parallel work are intentionally left for a follow-up once the option shape is agreed, to keep this PR reviewable.

Tests

Three focused cases in test/server/typegen.ts, using the existing fixtures (users.id/todos."user-id" are int8, the days_since_event computed field is numeric, details_length is int4):

  • bigintAs defaults to number: back-compat, "user-id": number, days_since_event: number | null.
  • bigintAs=string: "user-id": string, days_since_event: string | null, while details_length stays number | null.
  • bigintAs=bigint: "user-id": bigint, days_since_event: bigint | null, details_length stays number | null.

The details_length assertion is the regression guard proving int4 is unaffected. Used toContain (as the schema-filter tests already do) rather than full snapshots, to keep the surgical behavior of the option legible. Happy to convert to inline snapshots if you'd prefer consistency with the other typegen cases.

Docs

No README change: the README documents only the DB connection vars, none of the existing generator options (INCLUDED_SCHEMAS, DEFAULT_SCHEMA, SWIFT_ACCESS_CONTROL) live there, and the route uses inline types rather than a Fastify JSON schema, so there is no OpenAPI entry to update. Glad to add docs wherever you would want them.

Thanks @mandarini for transferring the issue with the typescript.ts:889 pointer and the wire-format context. That scoping is what made this a clean, contained change. Happy to adjust direction on any of the above.

@Maliik-B Maliik-B requested review from a team, avallete and soedirgo as code owners June 13, 2026 22:53

@avallete avallete left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi there ! Thank you for your contribution !

I would like to have this paired with an "e2e" test over the postgrest-js api, we already have bigint columns in the database for it:

https://github.com/supabase/supabase-js/blob/master/packages/core/postgrest-js/test/supabase/migrations/00000000000000_schema.sql#L23-L26

Within it's own file similar to: https://github.com/supabase/supabase-js/blob/master/packages/core/postgrest-js/test/basic.test.ts

Idea would be to demonstrate that this type adjustment actually match the postgrest behavior at runtime, including how the bigint type is used.

Right now the way I see it, this type can only work if there is a casting over the columns from Postgrest. Also what happen on "update" ? Would be good to demonstrate that this type adjustment actually fix the original problem (precision loss) and match the runtime behaviour.

@Maliik-B

Copy link
Copy Markdown
Author

Thanks for the review, and for pointing at the existing bigint fixtures and basic.test.ts.

You're right that on reads the generated type only lines up at runtime once the int8/numeric columns are cast (e.g. ::text), since PostgREST returns an un-cast int8 as a lossy JSON number. That read-side casting dependency is why the option is opt-in and defaults to number. Writes are cleaner: passed as a string, the value goes in losslessly with no cast. Happy to demonstrate both end to end rather than leave it at the type level.

My plan for the e2e, as its own file under packages/core/postgrest-js/test/ against the existing bigint columns:

  • the default un-cast read returns a JS number and loses precision for a value above 2^53,
  • writing that value as a string (the shape bigint_as=string produces for Insert/Update) stores it in Postgres exactly, no cast needed on the write side,
  • reading it back with the column cast to text returns the same string with no loss,
  • so the generated Row/Insert/Update types under bigint_as=string match what the client sends and receives end to end.

Does that line up with what you had in mind, or would you rather I scope it differently (for example just the supported cast path)?

Copy link
Copy Markdown
Member

Sounds good, minor nitpick, I think the value should be set in postgres to test the "read" with both (with and without cast).

Also should try the "update" via api to see if we can actually set 2^53 value from this path. I believe that will cause an issue.

In my mind the types should follow the runtime behavior, so if the runtime doesn't properly handle those, I think we should document this limitation and bubble up a fix at the PostgREST / sdk level first. Then, we can fix the types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants