Background image

Product Updates

Evolving enums for evolving APIs

Georges Haidar

Georges Haidar

May 15, 2024

Featured blog post image

Today we’re announcing support for “open” enums in our Go, Python and TypeScript code generators. This feature will help you ship SDKs that continue to work as you evolve your API and without causing your users unnecessary dependency management churn.

What even is that?

An enum is considered “closed” when it specifies a strict set of members like this TypeScript enum:

enum Color {
Red = "red",
Green = "green",
Blue = "blue",
}
const value: Color = Color.Red;
// ^ This can only be one of the three declared colors

Other languages, like Python and Java, treat enums similarly by default for example.

An “open” enum, on the other hand, is one where unrecognized values can be expressed alongside the known members. Some languages may not have a way to make enums open but we can find a way such as with branded types (opens in a new tab) in TypeScript:

declare const brand: unique symbol;
type Unrecognized = string & { [brand]: "unrecognized" };
function unrecognized(value: string): Unrecognized {
return value as Unrecognized;
}
enum Color {
Red = "red",
Green = "green",
Blue = "blue",
}
type OpenColor = Color | Unrecognized;
const stillWorks: OpenColor = Color.Blue;
const value: OpenColor = unrecognized("pink");
const badValue: Color = value;
// ~~~~~~~~
// Type 'Unrecognized' is not assignable to type 'Color'. (2322)

We’ll continue using TypeScript as the target language for the rest of this post.

The problem

Enums in OpenAPI and JSONSchema have presented developers with a unique challenge when it comes to making updates to their API. Let’s consider an example where we have an API to fetch a blog post from a CMS:

openapi: 3.1.0
info:
title: Blog CMS API
summary: The API for the content management system of our blog.
version: 0.1.0
servers:
- url: https://headless-cms.example.com/api
paths:
/posts/{slug}:
get:
tags: [posts]
operationId: get
parameters:
- name: slug
in: path
required: true
schema:
type: string
responses:
"200":
description: A blog post
content:
application/json:
schema:
$ref: "#/components/schemas/BlogPost"
components:
schemas:
BlogPost:
type: object
required: [id, slug, title, readingTime, markdown, category]
properties:
id:
type: string
slug:
type: string
title:
type: string
readingTime:
type: integer
markdown:
type: string
category:
type: string
enum:
- photography
- lifestyle
- sports

If we’re building a TypeScript SDK with Zod (opens in a new tab) for runtime validation from this API description, the code for the BlogPost schema might look like this:

import { z } from "zod";
export enum Category {
Photography = "photography",
Lifestyle = "lifestyle",
Sports = "sports",
}
export const BlogPost = z.object({
id: z.string(),
slug: z.string(),
title: z.string(),
readingTime: z.number().int(),
markdown: z.string(),
category: z.nativeEnum(Category),
});
export type BlogPost = z.output<typeof BlogPost>;
// ^? type BlogPost = { id: string; slug: string; title: string; readingTime: number; markdown: string; category: Category; }

We’re showing an example here with TypeScript enums but the problem we’re solving is also present when using literal string unions like if we were to define Category as:

export const Category = z.enum(["photography", "lifestyle", "sports"]);
export type Category = z.infer<typeof Category>;
// ^? type Category = "photography" | "lifestyle" | "sports"

The Speakeasy TypeScript SDK generator lets you choose if you want to generate TypeScript-native enums or literal unions.

We ship version 1 of our SDK and users can interact with the API like so:

import { CMS } from "@acme/cms";
const cms = new CMS();
const post = await cms.posts.get({ slug: "my-first-post" });
console.log(post.category); // -> photography

Some time passes and we’ve decide to add a new “tech” category to our API. We update our API description as follows:

components:
schemas:
BlogPost:
type: object
required: [id, slug, title, readingTime, markdown, category]
properties:
id:
type: string
slug:
type: string
title:
type: string
readingTime:
type: integer
markdown:
type: string
category:
type: string
enum:
- photography
- lifestyle
- sports
+ - tech

Once we deploy this change to our servers, users on v1 start getting validation errors because the category tech is not recognised by the SDK.

Uncaught:
[
{
"received": "tech",
"code": "invalid_enum_value",
"options": [
"photography",
"lifestyle",
"sports"
],
"path": [],
"message": "Invalid enum value. Expected 'photography' | 'lifestyle' | 'sports', received 'tech'"
}
]

This is not a novel problem and depending on the language and API description format you’re using, enums are sometimes treated as “closed” by default which gives rise to this challenge with evolving APIs.

There is a longstanding GitHub issue (opens in a new tab) in the OpenAPI community to address this problem. In a different world, protobuf enums (opens in a new tab) have varied representations where certain languages translate them to open enums and others to closed.

How we’re solving it

Previously, the Speakeasy generator treated enums as closed and emitted code appropriately in target languages. Starting from today, we’re exposing an OpenAPI extension, x-speakeasy-unknown-values, to allow you to selectively mark certain enums in your API description as open.

To get started, add the extension to your enums:

components:
schemas:
BlogPost:
type: object
required: [id, slug, title, readingTime, markdown, category]
properties:
# ... other fields omitted for brevity ...
category:
type: string
+ x-speakeasy-unknown-values: allow
enum:
- photography
- lifestyle
- sports

SDKs generated after this change will now have a Category enum type that is open. For TypeScript, the code we generate is equivalent to the following snippet:

import * as z from "zod";
import { catchUnrecognizedEnum } from "../../types";
// ^ A utility to capture and brand unrecognized values
export enum Category {
Photography = "photography",
Lifestyle = "lifestyle",
Sports = "sports",
}
export const BlogPost = z.object({
id: z.string(),
slug: z.string(),
title: z.string(),
readingTime: z.number().int(),
markdown: z.string(),
category: z.nativeEnum(Category).or(z.string().transform(catchUnrecognizedEnum)),
});
export type BlogPost = z.output<typeof BlogPost>;
// ^? type BlogPost = { id: string; slug: string; title: string; readingTime: number; markdown: string; category: Category; }
type OpenCategory = z.output<typeof BlogPost>["category"];
// ^? type OpenCategory = Category | Unrecognized<string>

In case you’re interested, here’s how catchUnrecognizedEnum works to create a branded type:

declare const __brand: unique symbol;
export type Unrecognized<T> = T & { [__brand]: "unrecognized" };
export function catchUnrecognizedEnum<T>(value: T): Unrecognized<T> {
return value as Unrecognized<T>;
}

Speakeasy TypeScript SDKs explicitly emit TypeScript types that are tied to Zod schemas instead of being inferred from them.

Continuing with our example above, when the new “tech” category is introduced, the following code continues to compile and work:

import { CMS } from "@acme/cms";
const cms = new CMS();
const post = await cms.posts.get({ slug: "my-first-post" });
// ^ Previously, this would throw a validation error
console.log(post.category); // -> tech

Users of the SDK also get the editor support they’re used to when working with enums. For instance, switch-blocks work great for branch logic over enums:

import { CMS } from "@acme/cms";
import { Unrecognized } from "@acme/cms/types";
// ...
let icon: "📸" | "🎨" | "🏈" | "❓";
switch (post.category) {
case "lifestyle":
icon = "🎨";
break;
case "photography":
icon = "📸";
break;
case "sports":
icon = "🏈";
break;
default:
post.category satisfies Unrecognized<string>;
// ^ Helps assert that our switch cases above are exhaustive
icon = "❓";
break;
}

Great! It seems we’ve done a lot of work to get back to client code that continues work. The net outcome, however, is that we’ve made more room for our APIs to evolve without causing issues for users on older versions of our SDK. This is in addition to retaining good type safety, backed by strict runtime validation, and great developer experience (editor auto-complete continues to work for open enums).

Wrapping up

The “open” enums feature, using the x-speakeasy-unknown-values extension, is available for Go, Python and TypeScript SDKs with support for additional language targets being added in the future. Check out our docs on customizing enums to learn about this and other customization options.

CTA background illustrations

Speakeasy Changelog

Subscribe to stay up-to-date on Speakeasy news and feature releases.