Forward compatibility
APIs grow. A new enum member, a new response field — neither is a breaking change on the server, and neither should break an SDK a consumer pinned last quarter. Generated SDKs are built so they don’t.
Three guarantees, all on by default, none requiring a regeneration to benefit from:
- Open enums — a value outside the set the SDK was generated from decodes, round-trips, and is detectable.
- Unknown response fields — a field the SDK was never generated from is retained on decode and written back out.
- Per-call escape hatches — you can send parameters your spec doesn’t model yet.
Open enums
When your server starts returning status: "archived" and the SDK was generated when the set was
active | inactive, a conventional generated enum throws on decode. Glotto’s don’t. An
unrecognized value satisfies three properties:
- It decodes without crashing. No exception, no dropped response.
- It round-trips at its original value. Read-modify-write sends
archivedback, not a fallback or a null. - It is detectable. You can tell a server-added value from a known member and branch on it.
This holds for string enums and, since Glotto took the deferred integer decision, for numeric enums too — the same three-part contract over a different primitive.
In TypeScript that surfaces as an open union, so the known members still autocomplete while any other value is accepted:
examples/realworld-assistant-api/inputs — every byte below is sliced from that demo or from one real generate run over it. What Glotto emits — an OPEN union, so `archived` still decodes
typescript/src/models/message-role.ts MessageRole
export type MessageRole = 'user' | 'assistant' | (string & {}); 4442693d5c56f9bfdd21804afa01eb02c8fe16c0f3f9d2813848d267301b81e1 What Glotto emits — the same shape over a number
typescript/src/models/error-response-status.ts ErrorResponseStatus
export type ErrorResponseStatus = 400 | 401 | 429 | 500 | (number & {}); a6ee2afd7e6f9fe6638e48da045c76eae987bf808bc72132899fdf8f7eff6936 Checking for a value this SDK predates is the comparison you would write anyway: it equals none of the members above. Log it, route it, or ignore it — it survives re-encoding either way.
The
& {}is load-bearing, not noise. A plain'active' | 'inactive' | stringcollapses tostringand discards the known members — which are the documentation of what the API actually accepts.
How it surfaces in your language
Every engine keeps the same guarantee and spells it the way its language already handles extensible values:
| Language | Surface | Detect an unrecognized value |
|---|---|---|
| TypeScript, React Native | Open union (spelled out below) | It equals none of the generated members |
| Python | StrEnum / IntEnum with a _missing_ hook returning a value-preserving pseudo-member |
.is_known is False |
| Swift | A RawRepresentable struct wrapping the raw value, known members as static constants |
isKnown is false |
| C#, Dart, Java, Kotlin | The same value-preserving wrapper shape, known members as constants | It equals no generated constant |
| Rust | A #[non_exhaustive] enum carrying the unrecognized value |
Match its unknown variant |
| PHP | A union of the generated enum and its primitive (spelled out below) | The value is the raw primitive, not an enum case |
| Go, Ruby, Elixir | Raw-value passthrough — the declared type is the primitive | Compare against the generated constants |
Open unions are 'a' | 'b' | (string & {}) and 200 | 404 | (number & {}).
For PHP, the exact surface is Status|string: the generated enum united with its primitive.
The passthrough row is not a weaker guarantee, just a different one: in those languages the declared type never constrained the value in the first place, so an unrecognized member was always going to arrive intact. The comparison you write is the same.
Mixed and boolean enums stay closed by design. A mixed enum has no single primitive to be open
over, and a boolean enum is already total — there is no value outside true | false to be
forward-compatible with.
Unknown response fields
The second leg covers fields rather than values: a response field your SDK was never generated from is retained on decode, readable through an accessor, and written back out on re-encode — so a read-modify-write never silently deletes data the SDK didn’t recognize.
Each language page documents the accessor its engine emits, in that language’s idiom: TypeScript · Python · Go · Ruby · Elixir — and the same section on every other Languages page.
Sending what your spec doesn’t model yet
The retained fields above are read-only by design: they record what the SDK received. To send a parameter your spec doesn’t model yet, use the per-call extra-query and extra-body escape hatches rather than writing into the retained bag. That keeps the two directions honest — retention is a record of the wire, and anything you add is something you chose to send.
Why this matters more than it sounds
Forward compatibility is what makes a pinned SDK safe to leave pinned. Without it, every additive server change is a silent deadline: consumers on older SDKs start throwing on values you consider non-breaking, and you find out from their bug reports. With it, the additive change is exactly what you intended it to be.