SDKs
Forward compatible SDKs: strict in development, lax in production
Thomas Rooney
February 5, 2026 - 10 min read
Your API returns a 200 OK. The HTTP request succeeded. Your customer’s application crashes anyway.
In the logs: ParseError: invalid enum value "paused" at response.data.status. The server added a new status behind a feature flag. The SDK, generated from last month’s spec, rejects the entire response because "paused" isn’t in "active" | "inactive". The customer’s dashboard goes blank. Support tickets pile up.
The tension is real: strict validation catches bugs early, but strict validation also breaks production code when APIs evolve. At Speakeasy, we’ve landed on a pattern that resolves this: strict in development, lax in production.
This is Postel’s Law
This post explains the pattern, shows how it works in TypeScript, and provides the configuration to implement it in Speakeasy-generated SDKs.
The problem: schema drift is inevitable
APIs evolve constantly. Backend teams ship changes independently. Feature flags and A/B tests expose new values to subsets of users. Multi-region deployments roll out gradually. Mobile apps linger on old versions for months.
Consider a typical scenario: your payments team adds a new "crypto" payment method on Tuesday. The change is behind a feature flag, enabled for 5% of users. Your SDK consumers are running a version generated three weeks ago. When one of those 5% of users triggers a request that returns the new payment method, the SDK rejects it. The customer sees an error. They didn’t change anything. You didn’t break the API. But their integration is broken.
Generated SDKs encode a snapshot of your API schema at a point in time. Runtime data inevitably moves faster than SDK releases. TypeScript types don’t validate at runtime, so strict mode implies a runtime parser that rejects unknown values. The failure modes are subtle:
- A new enum value causes deserialization to fail
- A new union discriminator has no matching type
- A missing required field throws before your code can handle it
- Type mismatches cause unexpected runtime behavior
The frustrating part: these aren’t bugs. The API returned valid data. The SDK just didn’t know how to handle it.
At scale, slow upgrades turn routine API evolution into a minefield. Companies like OpenRouter and PlanetScale ship SDKs to thousands of developers. The SDK that worked yesterday breaks today, even though nothing changed on the customer’s side.
Strict mode: fail fast, surface drift immediately
Strict mode is for environments you control: internal services, CI pipelines, integration tests, and the “golden path” where server and client deploy together.
In strict mode, the SDK enforces exact conformance to the schema:
// Strict mode: closed enum
type OrderStatus = "placed" | "approved" | "delivered";
// Server returns: { status: "cancelled" }
// Result: Parse error - "cancelled" is not a valid OrderStatus// Strict mode: closed union
type PaymentMethod =
| { type: "card"; last4: string }
| { type: "bank"; accountId: string };
// Server returns: { type: "crypto", address: "0x..." }
// Result: Parse error - unknown discriminator "crypto"// Strict mode: required field
interface Subscription {
id: string;
createdAt: Date; // required
}
// Server returns: { id: "sub_123" }
// Result: Parse error - missing required field "createdAt"Each of these failures is correct behavior. The response doesn’t match the schema. Strict mode says: reject it.
This is exactly what you want during development. Strict mode makes your OpenAPI spec a real contract, not just documentation. API drift surfaces immediately. Contract violations fail loudly. Schema changes become intentional decisions that flow through your regeneration pipeline.
At Speakeasy, we run strict mode in CI against staging for Gram
Lax mode: accept reality, degrade gracefully
Lax mode is for SDKs shipped to external customers, where you don’t control deployment cadence.
The philosophy: keep the application running, preserve unknown data, don’t pretend you know more than you do.
What lax mode is not
Lax mode is not “accept any shape and pretend it’s valid.” It’s not “swallow errors silently.” It’s not “turn everything into any.”
Lax mode means: parse what you can, preserve what you can’t, and ensure the TypeScript type contract holds at runtime so your application doesn’t crash on property access. Unknown values are captured in type-safe wrappers, not dropped.
Open enums
When the API adds a new enum value, the SDK captures it rather than crashing:
export type OrderStatus = "placed" | "approved" | "delivered" | Unrecognized<string>const order = await sdk.orders.get("order_123");
switch (order.status) {
case "placed":
case "approved":
case "delivered":
// Handle known statuses
renderStatus(order.status);
break;
default:
// New status we don't know about yet
// TypeScript knows this is Unrecognized<string>
console.log("Unknown status:", order.status);
renderFallbackStatus();
}The default case keeps switch statements exhaustive while remaining forward-compatible. Your code compiles today and handles tomorrow’s enum values gracefully.
Open unions
When the API adds a new discriminated union member, the SDK preserves the raw data instead of failing:
// Lax mode: open union
type PaymentMethod =
| { type: "card"; last4: string }
| { type: "bank"; accountId: string }
| { type: "UNKNOWN"; raw: unknown }; // Automatically addedThe UNKNOWN variant captures any discriminator value the SDK doesn’t recognize, along with the complete raw payload for debugging or custom handling:
const subscription = await sdk.subscriptions.get("sub_123");
switch (subscription.paymentMethod.type) {
case "card":
console.log("Card ending in", subscription.paymentMethod.last4);
break;
case "bank":
console.log("Bank account", subscription.paymentMethod.accountId);
break;
case "UNKNOWN":
// New payment method type we don't know about yet
// The raw payload is preserved for inspection
console.log("Unknown payment method:", subscription.paymentMethod.raw);
break;
}This keeps your switch exhaustive. TypeScript enforces that you handle UNKNOWN, so you can’t accidentally ignore new union variants.
Zero-value defaults
When a required field is missing or null, the SDK fills in a type-appropriate default rather than crashing:
| Field type | Missing value | Default |
|---|---|---|
| Required string | null or undefined | "" |
| Required number | null or undefined | 0 |
| Required boolean | null or undefined | false |
| Required date | null or undefined | new Date(0) (Unix epoch) |
Why default instead of throwing? Because the alternative is a white screen.
For read-heavy views (dashboards, lists, detail pages), rendering a default state is almost always better than crashing the entire component tree. The TypeScript type system still marks the field as required, so your code compiles correctly. The runtime object matches the TypeScript contract, preventing Cannot read property 'x' of undefined errors in your view layer.
For write-heavy logic where data integrity matters, use strict mode in your tests and CI to catch these issues before they reach production.
Type coercion
APIs are messy. JSON has no date type. Some backends return booleans as strings. Some return numbers as strings. Lax mode applies safe, unambiguous coercions:
// API returns: { isActive: "true", retryCount: "3" }
// SDK produces: { isActive: true, retryCount: 3 }Coercions include:
"true"/"false"strings (case-insensitive) to booleans- Numeric strings to numbers (when the target type is number)
- ISO-8601 strings and Unix timestamps to Date objects
The SDK only coerces when the transformation is unambiguous and not lossy. Arbitrary strings don’t become booleans. Non-numeric strings don’t become numbers. The goal is interoperability, not magic.
Configuring strict vs lax mode
In your gen.yaml, enable lax mode for external SDKs:
typescript:
forwardCompatibleEnumsByDefault: true # Open enums with Unrecognized<string>
forwardCompatibleUnionsByDefault: tagged-only # UNKNOWN variant for unions
laxMode: lax # Zero-value defaults, type coercion
unionStrategy: populated-fields # Smart union matchingFor internal SDKs where you want strict validation:
typescript:
forwardCompatibleEnumsByDefault: false # Closed enums, reject unknown values
forwardCompatibleUnionsByDefault: false # Closed unions, reject unknown discriminators
laxMode: strict # Throw on missing/mistyped fields
unionStrategy: left-to-right # First-match union parsingThe recommendation: ship lax mode SDKs to external customers (npm, PyPI), and use strict mode in your CI/CD pipeline and internal services.
| Environment | Mode | Why |
|---|---|---|
| Local development | Strict | Catch issues while coding |
| CI/CD pipeline | Strict | Fail builds on schema drift |
| Internal services (coordinated deploys) | Strict | Server and client update together |
| Public SDK (npm, PyPI) | Lax | Customers upgrade on their schedule |
The golden path
The pattern works like this:
-
Internal development uses strict mode. Your CI runs against the staging API with strict validation. Any schema drift fails the build immediately. You catch the problem before your customers do.
-
External SDKs ship with lax mode. Customers on older SDK versions continue working when you add new enum values or union types. Their dashboards don’t go blank.
-
Schema updates flow through your pipeline. When you add
"cancelled"toOrderStatus, you regenerate the SDK, bump the version, and publish. Strict mode ensures the spec matches the API. Lax mode ensures customers have time to upgrade. -
Feedback loop: When customers encounter unknown values, they show up in the
UNKNOWNvariant or asUnrecognized<string>. This signals which schema updates matter and when to release a new SDK version.
The blast radius of API changes shrinks dramatically. Internal changes break internal builds (good, you want that). External customers see graceful degradation (good, they want that). Nobody gets a 200 OK followed by a crash.
This is the same principle behind browser compatibility. Browsers don’t crash when they encounter an HTML tag they don’t recognize. They render what they can and skip what they can’t. SDKs should work the same way: strict during development (like a linter), permissive in production (like a browser).
What you gain
Forward compatibility isn’t just about avoiding breakages. It changes how you think about API evolution:
- Ship faster: Add enum values and union types without coordinating SDK releases with API deployments. Your backend team doesn’t need to wait for SDK releases.
- Upgrade gracefully: Customers adopt new SDK versions on their schedule, not yours. No more emergency upgrade notices.
- Debug easier: Unknown values are preserved, not dropped. When something unexpected appears, the data is there to investigate. The
UNKNOWNvariant andUnrecognized<string>type carry the original value. - Reduce support load: Fewer “SDK broke after API update” tickets. Fewer emergency SDK releases. Fewer angry tweets.
The operational benefits compound. When adding a feature doesn’t risk breaking customers, you ship features more often. When customers aren’t afraid of SDK upgrades, they upgrade more often. The whole ecosystem moves faster.
How this differs from other approaches
There are several ways SDK generators handle schema drift:
Strict-only generators (like most OpenAPI Generator targets) treat every deviation as a fatal error. This is correct behavior in a controlled environment, but it means external customers break whenever the API evolves. Adding a single enum value requires coordinating SDK releases with API deployments.
Permissive generators that return untyped data (any or raw JSON) avoid crashes but abandon type safety entirely. Developers lose autocomplete, compile-time checks, and the benefits of a typed SDK.
Optional-everywhere approaches make every field nullable. This prevents crashes but creates terrible developer experience. Every field access requires null checks even when the API guarantees the field exists. The SDK becomes a sea of ?. operators and if (field != null) guards.
Version-pinned SDKs tie SDK versions to API versions and refuse to parse responses from newer API versions. This works but creates operational burden, as customers must upgrade SDKs in lockstep with API updates. For APIs that evolve frequently, this becomes untenable.
Type-faith SDKs provide rich TypeScript types and cast the JSON payload directly into those shapes without runtime validation. This delivers great editor autocomplete and compile-time checking, but it assumes the server always matches the schema. When it doesn’t, failures surface later as generic JavaScript errors (accessing a property on undefined) rather than a targeted parse error with a path to the offending field. It can feel almost stainless—until the first unexpected field shows up and the mess appears at runtime. Runtime validation adds some overhead, but it makes forward-compatibility issues easier to detect and diagnose.
The strict/lax pattern preserves the best of both worlds: strong types, runtime safety, and forward compatibility. The SDK type contract stays honest. The runtime behavior adapts to reality.
How Speakeasy handles this
Speakeasy SDKs implement this pattern by default. New TypeScript SDKs ship with lax mode enabled: open enums, open unions, smart union deserialization, and safe type coercion.
The types stay honest. The runtime stays resilient. Your CI catches drift. Your customers don’t crash.
If you’re building APIs for external developers, forward compatibility isn’t a nice-to-have. It’s how you keep “200 OK” from turning into incidents.
Try Speakeasy on your OpenAPI spec: Run speakeasy quickstart to generate a forward-compatible TypeScript SDK. Or check out the TypeScript SDK documentation to see how open enums, open unions, and lax mode work under the hood.