Speakeasy Logo
Skip to Content

Feature

TypeScript forward compatibility and fault tolerance

David Adler

David Adler

January 28, 2025 - 6 min read

Feature

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.

  1. Forward-compatible enums
  2. Forward-compatible unions
  3. Lax mode
  4. Smart union deserialization

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: true

When 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-only

Specific 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 behavior

Lax 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 TypeMissing ValueDefault
Required stringnull or undefined""
Required numbernull or undefined0
Required booleannull or undefinedfalse
Required datenull or undefinedDate(0) (Unix epoch)
Required literalnull or undefinedThe literal/const value
Required bigintnull or undefinedBigInt(0)

For nullable and optional fields, lax mode handles the common confusion between null and undefined:

  • Nullable field that received undefined is coerced to null
  • Optional field that received null is coerced to undefined

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-fields

The 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.

Last updated on

Build with
confidence.

Ship what's next.