Skip to content

API Versioning

A versioned IDL describes one contract: the latest state of the API. The version history is declared next to it, and older versions are derived from annotations instead of copied files.

#pragma xidlc package petstore
@version
enum ApiVersions {
@rename("1.0") v1,
@rename("2.0") v2,
};
struct Pet {
string id;
// introduced in v2
@added(ApiVersions::v2)
@optional string note;
// deprecated at v2 and gone from then on
@removed(ApiVersions::v2)
string color;
};
interface Pets {
@get(path = "/pets/{id}")
Pet get(@path string id);
@removed(ApiVersions::v2)
@get(path = "/legacy")
string legacy();
};

@version marks exactly one enum as the API version timeline. Its members are the versions, in declaration order; the last member is the version the source describes. Annotate a member with @rename("...") to give it a display value such as a date or a semantic version:

@version
enum ApiVersions {
@rename("2025-01") v2025_01,
@rename("2025-06") v2025_06,
@rename("2026-01") v2026_01,
};

The member name (v2025_06) is the stable machine name used in output paths and selection flags; the value (2025-06) is what versioned documents carry. Per-version documents ignore #pragma xidlc version and use the projected version value as info.version.

Use @version(in = "header" | "query" | "cookie", name = "...") to declare where clients send their module’s version. Both arguments are required; in ignores case. A bare @version does not add a transport parameter.

@version(in = "query", name = "api_version")
enum ApiVersions {
@rename("2025-01") v2025_01,
@rename("2025-06") v2025_06,
};

Header and cookie names must be non-empty HTTP tokens. Query names must be non-empty and contain no control characters. A version parameter cannot share its name and location with an operation parameter or an API key. Header names ignore case; query and cookie names are case-sensitive. A version header also cannot overwrite a business response header. Header version values must be ASCII without leading or trailing HTTP whitespace. Transport fields such as Content-Type, Cookie, Set-Cookie, and WebSocket handshake headers cannot be used as version headers; an active authentication scheme also reserves Authorization. Query and cookie values are encoded for their transport.

Each OpenAPI operation declares an optional parameter with a singleton enum containing its own version. Generated Rust, TypeScript, and Go REST clients pin the parameter after request parameters and credentials are applied. Other query parameters and cookies in the request are preserved. For Rust, pass cookies through operation parameters or authentication; cookies hidden in a custom reqwest client’s default headers cannot be merged with an explicit generated Cookie header.

With in = "header", every generated operation response declares the header and the server writes its own module’s version, including errors. A v2 server writes v2 even when the request carries v1, an unknown value, or no version. Query and cookie parameters are not echoed.

Servers never resolve the version. Routing to a version remains the application or gateway’s responsibility. A generated server does not read the version parameter to choose a module or reject a request.

Location HTTP clients WebSocket handshake
header Rust, TypeScript, and Go REST send the pinned header Rust and Node send it; browsers use the server default
query Every client sends the encoded query parameter Every client, including browsers, pins it in the URL
cookie Node and Rust send Cookie; browser HTTP and WebSocket clients use app-set cookies Node and Rust send Cookie; browsers use app-set cookies

Browser WebSocket constructors cannot set custom headers. In Node, pass a headers-capable factory to the generated open_*Session helper:

import WebSocket from "ws";
const session = await open_watchSession(baseUrl, {
connect: ({ url, protocols, headers }) =>
new WebSocket(url, protocols, { headers }),
});

The generated helper combines caller headers with the pinned version before calling connect. Browser callers can use the default WebSocket constructor; query versions work without a custom factory. An application’s WebSocket upgrade entry point owns the HTTP handshake response; generated TypeScript server operation metadata exposes response.fixedHeaders for that entry point.

Annotation Meaning
@added(Versions::vN) the declaration exists from that version onward
@removed(Versions::vN) the declaration is removed at that version

Both can appear on modules, type declarations (structs, unions, enums, exceptions, typedefs, bitsets, bitmasks), struct/union members, enumerators, bitmask values, interfaces, operations, operation parameters and interface attributes.

Rules:

  • Annotation order never matters. @added(v2) @removed(v3) and @removed(v3) @added(v2) describe the same interval.
  • Each declaration carries at most one @added and one @removed, and @added must come strictly before @removed. Cycles such as @added(v2) @removed(v3) @added(v4) are rejected; declare a second member with a disjoint interval instead.
  • A child’s interval must stay inside its parent’s. A member added in v1 of a struct added in v2 is an error.
  • A surviving declaration must not reference a type that the projected version drops. Such a reference is reported with the declaration and the version.
  • raises(...) entries are the one filtered position. An exception set has no annotation slot, so an operation raises a versioned exception only in the versions that declare it: older projections drop the entry.
  • Root-qualified references (::Models::Item) keep pointing at the same declaration: projection rewrites them relative to the version module, so they resolve inside module <version> rather than at the file root.

Unsupported versioning annotations (@versioned, @renamedFrom, @replaces, @madeOptional, @madeRequired, @typeChangedFrom, @returnTypeChangedFrom, @defaultVersion) are rejected with an explicit error instead of being silently ignored. These names do not imply planned support. Use @rename("wire_name") to select a serialized name; it does not describe a rename at a particular version. Changes between versions use the disjoint declarations described below.

A rename or a shape change is written as a new member with a disjoint interval; @rename keeps the wire name stable when needed:

struct Pet {
@added(Versions::v2)
@removed(Versions::v3)
int32 age;
@added(Versions::v4)
@rename("age")
int32 age2_;
};

The compiler does not infer links between removed and added fields, even when only their names differ. Application hooks explicitly assign the new value and acknowledge the old input with IGNORED. The downcast hook obtains old facts from StoredValues, or returns OMIT for an absent optional fact. NEEDS continues to report the old wire name while a supported version reads it; a reader may obtain that fact directly from Head when the application knows the two fields have identical meaning.

struct Parcel {
@removed(Versions::v3) @optional string courier_note;
@added(Versions::v3) @optional string courier_remark;
};
// Parts of the application's generated VersionAdapters / StoredValues contract.
parcelUpcastFromV2: source => ok({
courier_remark: source.courier_note,
courier_note: a.IGNORED,
}),
parcelDowncastToV2: (source, stored) => ok({
courier_note: stored.parcelCourierNote(source) ?? a.OMIT,
}),
parcelCourierNote: source => source.courier_remark,

Both a supplied value and its absence survive this application’s round trip. The assignments are business decisions, not a generated matching rule.

Projection wraps every version in module <member-name> { ... }, evaluates declaration availability, and removes lifecycle annotations before rendering the per-version contract.

Code targets keep one module per version inside a single artifact:

out/petstore.rs
pub mod v1 { /* structs, traits and servers for v1 */ }
pub mod v2 { /* structs, traits and servers for v2 */ }

OpenAPI and OpenRPC emit one document per version, named after the version member:

out/openapi_petstore.v1.json # info.version = "1.0"
out/openapi_petstore.v2.json # info.version = "2.0"

To freeze released documents in CI, regenerate the version directory and let the repository review the byte-level result:

Terminal window
xidlc gen --out-dir api/versions openapi api.idl
git diff --exit-code api/versions

For a human-readable contract review, compare two projections as formatted IDL:

Terminal window
xidlc version diff v1 v2 api.idl

The two version arguments accept enum member names or display values. The diff shows contract annotations, routes, and parameter locations, while ignoring source whitespace and documentation comments. Identical projections produce no output and exit with 0; differences exit with 1; invalid inputs or output failures exit with 2. Unknown versions report the available choices.

Every declared version is emitted by default. Narrow the output with --api-version (one version) or --api-versions (a comma separated list, or * for every version). Selection accepts the member name or the display value, and rejects unknown versions with the list of declared ones:

Terminal window
xidlc gen openapi -o dist petstore.idl
xidlc gen openapi --api-version v1 -o dist petstore.idl
xidlc gen rust --api-versions v1,v3 -o dist petstore.idl

xidl-build exposes the same selection through Builder::with_api_version and Builder::with_api_versions.

Generate a language-neutral table for the complete timeline:

Terminal window
xidlc gen --out-dir out version-map petstore.idl

out/petstore.version-map.json contains direct changes between adjacent versions. The same JSON value is available to generators and plugins as props.version_map. Version selection does not narrow this table. Keys are sorted, indentation uses two spaces, and the file ends with a newline.

Format 2 includes declaration_id in member, enumerator, union-case, operation, and parameter descriptors. Request parameters are in parameters; output and inout parameters also appear in response_parameters, keyed by location and wire name. Each response binding contains an array of descriptors in source order, including single-item bindings; this preserves repeated response headers. An inout declaration has the same token on both sides. The synthetic return value has no parameter identity. It distinguishes two declarations that happen to have the same name, wire name, and type. The same source declaration keeps its token across projections; two distinct declarations receive different tokens. Tokens are opaque and local to the current source, so consumers must compare them within one table and must not use them as persistent identifiers across source revisions. Whitespace and comment changes preserve the tokens.

This changes the meaning of an omitted change: equal endpoint shapes alone no longer hide a declaration replacement. Format 2 readers can distinguish that replacement without recovering lifecycle annotations from projected HIR. The table records facts; applications and optional adapter targets decide how to convert values, resolve API versions, and route requests.

Versioned contracts project into one module per version, so they need targets whose module support renders a single module per API version:

Target Versioned contracts
rust, rust-axum, typescript, typescript-rest, go-rest supported
openapi, openrpc supported, one document per version
go, rust-jsonrpc rejected with a clear error

The remaining targets already misinterpret modules today (they repeat the module wrapper or reference unqualified type names), so generation for them fails loudly instead of emitting code that does not compile.

Serving several versions from one implementation (runtime conversion) is not part of this feature; each version module is an ordinary generated server that can be started on its own.

Generate the ordinary SDK and the adapter module independently:

Terminal window
xidlc gen --out-dir gen typescript-rest api.idl
xidlc gen --out-dir gen typescript-rest-adapters api.idl

The second target writes only api.adapters.ts. Ordinary SDK and server artifacts are unchanged. It consumes projected HIR and format-2 props.version_map; the compiler core records facts and does not select an application architecture.

The application resolves a version identifier and extracts HTTP parameters. Pass a source-parameter envelope to dispatch<Interface><Operation>(version, raw, handler, adapters): the envelope uses IDL parameter names, while nested JSON payloads retain wire keys. For example, { parcel: await request.json() } preserves unknown fields for served-version validation. Do not deserialize the payload first: an ordinary schema may discard fields that an older version must reject. Header, query and cookie parameters with the same wire name remain separate source parameters.

Generated decoders recursively validate required fields, named types, sequences, fixed arrays, records and unions before calling any conversion hook. Renamed keys and flattened fields become model properties only after wire validation. Unknown object keys and known fields belonging to another version are rejected; flattened records retain their own arbitrary keys. Reserved __proto__ object keys are rejected before Zod can discard them.

Implement the generated VersionAdapters interface with pure decisions. Missing hooks fail TypeScript compilation. Removed input members require IGNORED; optional additions remain absent unless a simultaneous removal/addition requires an explicit replacement decision. An addition that declares @default is the other exception: the Head handler would otherwise apply the new default to every older request, so the upcast decision requires the value each older version implied, as a plain (non-optional) field. When that value equals the new default the hook returns the default - the equality is a product fact and stays visible in code. The version map records every declared default on its member descriptor, so a default that changes between versions shows up as a fact rather than as silent behaviour drift. Conversion follows every adjacent step to Head, short-circuiting on refusal. Handlers receive both the original version id and its declared display value. Typed exceptions downgrade through every step; framework errors preserve their identity.

A member an older version declares and Head removed is always an explicit decision, derived from @added / @removed alone. On the way up the hook acknowledges the value with IGNORED. On the way down the decision requires the member: read it from storage through the generated StoredValues interface, or write OMIT to leave it absent — a key can no longer disappear silently. A required retired member has no OMIT escape: the decision needs a value, and a hook that cannot supply one refuses with UnsupportedProjection.

Each reader is a pure accessor for one retired member, named after its type or response (parcelHandlingNote, depotTraceResponseTraceId). The reader receives the newer-side instance that owns the member — for an operation response, that is the response value. The application resolves identity itself: a reader can only recover a per-instance value when the instance carries something addressable (an id, a natural key); otherwise the reader returns undefined and the decision omits or refuses. The chain stays pure and never touches storage. NEEDS lists, per served version and per type or operation key, the wire names to preload; Head reads nothing. Because readers are synchronous, batch reads follow a two-phase shape: preload the rows for NEEDS[version] in the handler, then return readers that close over them — one batch per request, whatever the response size. Dispatch handlers return { stored } beside their outcome so the whole downcast chain receives the same readers. Serving Head through the aggregate dispatch entry still expects a stored value; the per-version entries keep the exact signatures.

downcast<Interface><Operation>OutcomeTo<Version> preserves the original Head outcome when conversion fails. A write may already have committed: the application owns idempotency, recording that outcome and rendering its declared refusal error. Dispatch never retries the handler. downcast<Type>(version, head, adapters) exposes the same complete chain for reachable result types.

Phase one rejects retired or replaced operation declarations, stream/upgrade frame conversion, inherited structs, custom template types and inline union case declarations whose nested identity is not exposed by the public map. These are explicit generation errors, not silent field loss. This target does not read version headers, choose routes, provide defaults or infer rename intent.

The Rust target writes a separate artifact beside the ordinary Axum models:

Terminal window
xidlc gen --out-dir gen rust-axum --client --server api.idl
xidlc gen --out-dir gen rust-axum-adapters api.idl

Include api.rs and api.adapters.rs in the same Rust module. Implement the mandatory VersionAdapters trait with pure hooks, then pass an explicitly resolved Version to dispatch_<interface>_<operation>. As in TypeScript, raw request envelopes use IDL parameter names and nested payloads use wire keys. The application owns parameter extraction, version resolution and routing.

The decoder constructs the exact served model while checking its wire layout. Nested structs, flattened records, arrays, recursion and unions follow the same rules before any hook or handler runs. Numeric record keys use ordinary Serde map-key decoding; distinct wire keys that decode to the same key are rejected. An HTTP union constructs its strictly matching variant directly; ambiguous payloads are rejected instead of choosing a different variant and losing data.

Conversions borrow their sources. downcast_<interface>_<operation>_outcome returns the original owned Head result on refusal, including composite responses and typed errors that do not implement Clone. The Head identity branch moves that result directly. downcast_<type> consumes its Head model so its identity branch also avoids a whole-value clone. Asynchronous dispatch accepts thread-safe hooks and can be awaited inside an Axum handler; it never retries the handler.

Retired members follow the same explicit-decision rule as TypeScript: an upcast acknowledges them with IGNORED, and a downcast decision requires the field — an optional retired member reads the generated StoredValues trait or writes None explicitly, a required one needs a value or a refusal. Every retired member needs one reader method, so a member retired too early shows up as a missing method, not as lost data. NEEDS lists the wire names each served version still reads; Head reads nothing. Dispatch handlers return HeadOutcome { outcome, stored } so the chain receives the readers.

In addition to the shared phase-one limits, Rust rejects skipped physical model fields or cases and ordinary unions with multiple or default discriminator labels. Their public model constructors do not expose a lossless construction contract. Ordinary rust-axum output remains byte-identical when generating adapters separately.

A @patch or @put operation whose Head declaration returns one named struct takes part in the update flow when one adjacent step removes the same wire member from both its request payload and its return type. The retirement is then a replacement of a writable value, not a loss: the step’s upcast conversions return the persisted member to the application instead of acknowledging it.

Such conversions return an Upcast value that pairs the projected request with a stored bag holding every paired member collected along the chain. The bag is named after the operation (for example DepotUpdateStored) and merges across steps, so a multi-step chain hands the handler one complete bag. The decision hook of a paired conversion receives current, the Head return-type instance the application is about to patch, and its decision records both the acknowledgement (IGNORED) and the values to persist:

parcelPatchUpcastFromV2: (source, current) =>
ok({
handling_note: IGNORED,
stored: { handling_note: source.handling_note },
}),

decodeAndUpcast<Operation> and dispatch<Operation> accept current as an explicit parameter; the update handler receives it together with the context and the bag, persists the bag members with its own storage, and returns the Head outcome together with the stored values its response downcast still reads. Because the request member and the return member share one wire name, a value written by an old caller reads back identically: write-then-read and read-then-unchanged-write are golden properties of the generated chain.

The selection stays strict. Request chains must be operation-disjoint; two update operations may not share one request conversion. current never routes through alias or union conversions, and never reaches a paired conversion hidden inside a sequence, map, or optional member. Every one of these shapes is a generation error naming the offending declaration.