Feature
TypeScript forward compatibility and fault tolerance
David Adler
January 28, 2025 - 6 min read
As an end-user of an SDK, one of the most confusing experiences is receiving a 200 response from the server but also getting an error from the SDK. How can both things be true at the same time?
This happens more often than you might think, and there are two main causes: API evolution and inaccurate OpenAPI specs. When an API adds a new enum value, extends a union type, or doesn’t return a required field, SDKs with strict validation will reject the response even though the server successfully processed the request.
Server-side solutions
There are several approaches API providers can take to prevent these issues on the server side. One option is to tag clients with a version of the API and never send back schemas that could break older clients. Another approach is to add a validation layer on the server, generate specs from the implementation, or use a generated backend adapter layer. Contract testing can also help catch breaking changes before they reach production.
However, not all API backends have the discipline or hygiene to implement these solutions perfectly. Even when they do, it’s not always practical to version every response, and in many cases it makes more sense for the client to handle API evolution gracefully rather than requiring the server to maintain perfect backward compatibility forever.
Client-side solutions
Speakeasy SDKs provide several features to handle API evolution gracefully on the client side. These features ensure that your SDK continues to work even when the API evolves, without sacrificing type safety or developer experience.
Forward-compatible enums
Enums are one of the most common sources of breaking changes. When an API adds a new status, category, or type value, SDKs with strict enum validation will reject the entire response.
Forward-compatible enums are enabled by default for new TypeScript SDKs. You can also configure this explicitly in your gen.yaml:
typescript:
forwardCompatibleEnumsByDefault: trueWhen enabled, any enum used in a response will automatically accept unknown values. Single-value enums won’t be automatically opened. You can also control individual enums with the x-speakeasy-unknown-values: allow or x-speakeasy-unknown-values: disallow extension in your OpenAPI spec.
This is particularly useful because enums frequently get extended as products evolve. A notification system might start with email and sms, then later add push notifications. Without forward compatibility, SDK users would see errors until they upgrade.
The ergonomics are designed to not interfere with the happy path. When you receive a known value, everything works exactly as before. When you receive an unknown value, it’s captured in a type-safe way:
const notification = await sdk.notifications.get(id);
// Before: Error: Expected 'email' | 'sms' | 'push'
// After: 'email' | 'sms' | 'push' | Unrecognized<string>Forward-compatible unions
Similar to enums, discriminated unions often get extended as APIs evolve. A linked account system might start with email and Google authentication, then later add Apple or GitHub options.
Forward-compatible unions are also enabled by default for new TypeScript SDKs. You can configure this explicitly in your gen.yaml:
typescript:
forwardCompatibleUnionsByDefault: tagged-onlySpecific unions can be controlled with x-speakeasy-unknown-values: allow or x-speakeasy-unknown-values: disallow in the spec. When enabled, tagged unions will automatically accept unknown values and accessible via the UNKNOWN discriminator value.
const account = await sdk.accounts.getLinkedAccount();
// Before: Error: Unable to deserialize into any union member
// After:
// | { type: "email"; email: string }
// | { type: "google"; googleId: string }
// | { type: "UNKNOWN"; rawValue: unknown } (Automatically inserted when the union is marked as open)Lax mode
Sometimes the issue isn’t new values being added, but missing fields. A single missing required field can cause the entire response to fail deserialization, even when all the data you actually need is present.
Lax mode is also enabled by default for new TypeScript SDKs. You can configure this explicitly in your gen.yaml:
typescript:
laxMode: lax # or 'strict' for strict behaviorLax mode is inspired by Go’s built-in JSON unmarshal behaviour and Pydantic’s coercion tables. The key principle is that lax mode does not affect correctly documented OpenAPI specs / SDKs. When the server response matches the expected schema, no coercion is applied. Lax mode only kicks in when we have a valid HTTP response but the payload doesn’t quite match the schema.
Importantly, the SDK types never lie. End-users can trust the types with confidence because lax mode fills in sensible defaults rather than returning incorrect data. This approach is designed to only apply coercions which are not lossy.
For required fields that are missing, lax mode fills in zero values:
| 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 | Date(0) (Unix epoch) |
| Required literal | null or undefined | The literal/const value |
| Required bigint | null or undefined | BigInt(0) |
For nullable and optional fields, lax mode handles the common confusion between null and undefined:
- Nullable field that received
undefinedis coerced tonull - Optional field that received
nullis coerced toundefined
Lax mode also provides fallback coercion for type mismatches:
- Required string: any value coerced to string with
JSON.stringify() - Required boolean: coerces the strings
"true"and"false" - Required number: attempts to coerce strings to valid numbers
- Required date: attempts to coerce strings to dates, coerces numbers (in milliseconds) to dates
- Required bigint: attempts to coerce strings to bigints
Smart union deserialization
When deserializing union types, the order of options matters. The default left-to-right strategy tries each type in order of strictest first and returns the first valid match. This works well when types have distinct required fields, but can pick the wrong option when one type is a subset of another or when there is a lack of required fields.
Smart union deserialization with the populated-fields strategy is also enabled by default for new TypeScript SDKs. You can configure this explicitly in your gen.yaml:
typescript:
unionStrategy: populated-fieldsThe populated-fields strategy tries all typesand returns the one with the most matching fields, including optional fields. This approach is inspired by Pydantic’s union deserialization algorithm.
The algorithm works as follows: first, it attempts to deserialize into all union options and rejects any that fail validation. Then it picks the candidate with the most populated fields. If there’s a tie, it picks the candidate with the fewest “inexact” fields. An inexact field is one where some kind of coercion happened, such as an open enum accepting an unknown value or a string being coerced to a boolean.
This strategy is particularly useful when union options aren’t well-discriminated. For example, if you have a union of BasicUser and AdminUser where AdminUser has all the fields of BasicUser plus additional admin-specific fields, the populated-fields strategy will correctly identify admin users even if BasicUser is listed first in the union.
Conclusion
API evolution is inevitable, and spec drift happens even in the most disciplined organizations. Speakeasy SDKs are designed to handle these realities gracefully, keeping your SDK users productive even as your API grows and changes.
These features work together to provide a robust client experience: forward-compatible enums and unions handle additive changes, lax mode handles missing or mistyped fields, and smart union deserialization handles ambiguous type discrimination. All without sacrificing the type safety and developer experience that make TypeScript SDKs valuable in the first place.
Note: This post focuses specifically on TypeScript SDK behavior. Speakeasy SDKs for other languages (Python, Go, Java, and more) implement similar forward compatibility and coercion behaviors tailored to each language’s idioms. Stay tuned for upcoming posts covering these other languages.