# open-enums
Source: https://speakeasy.com/blog/open-enums


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:

```typescript
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][branded-types]
in TypeScript:

[branded-types]: https://egghead.io/blog/using-branded-types-in-typescript

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

```yaml
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: "Other"
      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][zod] for runtime validation from
this API description, the code for the `BlogPost` schema might look like this:

[zod]: https://zod.dev/

```typescript
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:

```typescript
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:

```typescript
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:

```diff
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][oapi-enums] in the OpenAPI community to
address this problem. In a different world, [protobuf enums][proto-enum] have
varied representations where certain languages translate them to open enums and
others to closed.

[oapi-enums]: https://github.com/OAI/OpenAPI-Specification/issues/1552
[proto-enum]: https://protobuf.dev/programming-guides/enum/

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

```diff
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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][customizing-enums] to learn about this and other customization options.

[customizing-enums]: /docs/customize-sdks/enums