TypeScript API 客户端/SDK 中的向前兼容性和容错性
Forward compatibility and fault tolerance in TypeScript API Clients/SDKs

原始链接: https://www.speakeasy.com/blog/typescript-forward-compatibility

## SDK 错误与 200 响应:处理 API 演进 SDK 用户常见的困扰是收到成功的 (200) HTTP 响应 *同时* 伴随 SDK 错误。这通常源于 API 演进——例如新的枚举值或缺失字段的变化——与严格的 SDK 验证产生冲突。虽然 API 提供者可以尝试预防措施,如版本控制或服务器端验证,但完美的向后兼容性通常是不切实际的。 Speakeasy SDK 通过优先处理 API 变化的客户端优雅处理来解决这个问题。主要功能包括:**向前兼容的枚举和联合类型**(自动接受新值而不破坏代码)、**宽松模式**(为缺失字段填充合理的默认值,而不是失败)和 **智能联合类型反序列化**(根据填充的字段智能选择正确的联合类型)。 这些功能默认启用于新的 TypeScript SDK 中,确保类型安全 *和* 流畅的开发者体验。它们允许 API 演进而不会立即破坏现有的集成,为不可避免的“规范漂移”提供了一个强大的解决方案,并保持 SDK 用户的生产力。Speakeasy 的其他语言 SDK 也提供类似的前向兼容性功能。

黑客新闻 新 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 TypeScript API 客户端/SDK 中的向前兼容性和容错性 (speakeasy.com) 3 分,mfbx9da4 发表于 1 小时前 | 隐藏 | 过去 | 收藏 | 讨论 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系方式 搜索:
相关文章

原文

David Adler

December 1, 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.

  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:

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 types and 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

联系我们 contact @ memedata.com