Skip to content

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

Open
Maliik-B wants to merge 2 commits 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 2 commits into
supabase:masterfrom
Maliik-B:fix/bigint-as-flag

Conversation

@Maliik-B

@Maliik-B Maliik-B commented Jun 13, 2026

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 that widens the write types for int8/numeric columns. It defaults to number, so existing consumers are unaffected.

The key change from the first revision: the option is applied per direction, not as a single scalar.

  • Row (read) stays number, always. PostgREST returns an un-cast int8 as a JSON number, so precision is already gone by the time JSON.parse runs. Typing the Row as anything else would misrepresent what actually comes over the wire. Consumers who need the exact value cast to ::text and get a string back, and the select-query parser already infers string for the casted column.
  • Insert/Update (write) widen to the bigint_as union. This is the lossless input channel: a JS BigInt is serialized by postgrest-js to a JSON string, so values past 2^53 go in intact. Widening the input type is pure back-compat (it only accepts more).

bigint_as is a pipe-delimited set passed as a single string, so the union is spelled inline, for example bigint_as=number|bigint. Accepted tokens are number and bigint; the recommended value is number|bigint (bigint for the lossless path, number for the ergonomic small-value case). Unknown tokens are ignored and an empty result falls back to number.

string is deliberately not accepted: an arbitrary non-numeric string would pass the type check and only fail at runtime in PostgREST. The documented pattern for the read-then-write case is to wrap the cast read value, update({ ... }).eq('id', BigInt(entity.id)).

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.

Runtime evidence

The read/write model above is demonstrated end to end in supabase/supabase-js#2461 (postgrest-js), which seeds 9223372036854775807 (2^63 - 1) in Postgres and exercises the API:

  • un-cast read returns the lossy JS number 9223372036854776000,
  • ::text read returns the exact string "9223372036854775807",
  • writing a value above 2^53 as a string (or a BigInt) round-trips losslessly,
  • writing it as a JS number is lossy before it ever leaves the client.

So the runtime already handles these values on both paths, and bigint_as makes the generated write types reflect the lossless one.

Scope

TypeScript template only, and within it the table/view Insert/Update types. Row types and function returns/arguments are left as number. 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 settled, to keep this PR reviewable.

Tests

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

  • bigint_as defaults to number: back-compat, "user-id": number (Row) and "user-id"?: number (Insert/Update).
  • bigint_as=number|bigint: "user-id"?: number | bigint on Insert/Update, while the Row stays "user-id": number and days_since_event: number | null.
  • bigint_as=bigint: "user-id"?: bigint on Insert/Update, Row unchanged.

Each case asserts the read/write asymmetry directly (the Row assertions are the regression guard that reads are never widened). 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 would 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, and @avallete for working through the read/write direction on #2461. That is what settled this into a contained, back-compatible 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.

@Maliik-B

Copy link
Copy Markdown
Author

Opened the e2e as supabase/supabase-js#2461 (postgrest-js). It seeds 9223372036854775807 (2^63 - 1) in Postgres and exercises read and write through the API:

  • Read, no cast: comes back as the JS number 9223372036854776000. Lossy, since PostgREST emits int8 as a JSON number and JSON.parse rounds it to the nearest double.
  • Read, ::text cast: the exact string "9223372036854775807". Lossless.
  • Write above 2^53 via the API as a string: round-trips losslessly. Updating with "9007199254740993" and reading back with a cast returns "9007199254740993". The precision loss is client-side, not PostgREST, so a string (or a BigInt, which postgrest-js already serializes to a string) goes in intact. So the update path does set these values correctly, as long as the value is not a JS number.
  • Write above 2^53 as a JS number: lossy, since Number("9007199254740993") is already 9007199254740992 before it is sent.

So the runtime does handle these values: a ::text cast on read, and a string (or bigint) on write. That is what bigint_as makes the generated types reflect. It stays opt-in and defaults to number, matching the un-cast read, so nothing changes for anyone who has not opted in. bigint_as=string makes the Insert/Update column string (the lossless write form), and the Row column string for the cast-on-read path the consumer has arranged.

The test also carries a @ts-expect-error on the string write: with the default number type the lossless string form is a type error today, which is the gap the option closes.

Does that cover what you wanted to see on the runtime side? Happy to adjust the test or the option scope from here.

avallete commented Jun 18, 2026

Copy link
Copy Markdown
Member

Cross posting here: supabase/supabase-js#2461 (review)

Per the read/write model worked out on supabase/supabase-js#2461: the Row stays number (an un-cast int8 is a lossy JSON number, and ::text already infers string on the casted column), and only Insert/Update widen to the bigint_as union. The option is now a pipe-split string accepting number and bigint; string is dropped, since an arbitrary non-numeric value would pass the type check and fail only at runtime in PostgREST.
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