Skip to content

Commit 0e38ee2

Browse files
committed
feat: wip, making enum evolution safer
# Conflicts: # packages/typescript-fetch-runtime/src/zod.ts
1 parent e318bc1 commit 0e38ee2

5 files changed

Lines changed: 80 additions & 3 deletions

File tree

63.9 KB
Loading
24.9 KB
Loading
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Safely evolving enumerations
2+
3+
OpenAPI allows you to define enumerated types, such as:
4+
```yaml
5+
type: string
6+
enum:
7+
- Apple
8+
- Banana
9+
- Orange
10+
```
11+
12+
At first glance, you might think this should be turned into a typescript type and runtime schema like:
13+
```typescript
14+
export type t_Fruit = "Apple" | "Banana" | "Orange"
15+
export const s_Fruit = z.enum(["Apple", "Banana", "Orange"])
16+
```
17+
18+
Looks ok right? However, what happens when we want to evolve this `enum`, perhaps we want to start stocking `Pear`.
19+
20+
In most cases, it's essentially impossible to roll out such a change to both clients, and servers instantaneously. Therefore,
21+
we need to think about version skew / compatibility.
22+
23+
Ideally:
24+
- Our servers will only ever accept/return currently valid enum values, they are source of truth
25+
- Our clients will gracefully handle unrecognized enum values, likely by ignoring them or the entity that contains them
26+
27+
We can achieve this by altering the types / schema in our clients slightly:
28+
```typescript
29+
export const s_unknown_enum_value = z.unknown().brand("unknown enum value")
30+
export type UnknownEnumValue = z.infer<typeof s_unknown_enum_value>
31+
32+
export type t_Fruit = "Apple" | "Banana" | "Orange" | UnknownEnumValue
33+
export const s_Fruit = z.union([z.enum(["Apple", "Banana", "Orange"]), s_unknown_enum_value])
34+
```
35+
36+
Now, we have a schema that will pass through unknown values as a branded unknown type. This prevents invalid/random values being referenced in the code, whilst also allowing us to make exhaustiveness checks.
37+
38+
![Example exhaustiveness error message](/enum-exhaustiveness-check.png)
39+
40+
```typescript
41+
function processFruit(result: t_Fruit): void {
42+
switch (result) {
43+
case "Apple":
44+
console.log("bite into apple")
45+
break
46+
case "Banana":
47+
console.log("slip over banana")
48+
break
49+
case "Orange":
50+
console.log("juice orange")
51+
break
52+
default: {
53+
// This checks that we have exhaustively handled the known values
54+
const _ = result satisfies UnknownEnumValue
55+
console.warn(`unsupported ${result}, skipping`)
56+
}
57+
}
58+
}
59+
```
60+
61+
Whilst technically `t_Fruit` can be any string value at runtime, you still won't be able to assign random values
62+
to it, as the branded type will not allow you
63+
![Example rejecting unknown enum value](/enum-reject-unknown.png)
64+
65+
This is interesting as it means that our server can start returning new enumerated values, before the clients have been
66+
updated to explicitly handle them. When dealing with native mobile applications, this can be especially important as
67+
there is often a long tail of older client versions in use, largely out of your control.
68+

packages/openapi-code-generator/src/typescript/common/schema-builders/zod-schema-builder.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,10 @@ export class ZodBuilder extends AbstractSchemaBuilder<
201201
if (model.enum) {
202202
// TODO: replace with enum after https://github.com/colinhacks/zod/issues/2686
203203
return [
204-
this.union(model.enum.map((it) => [zod, `literal(${it})`].join("."))),
204+
this.union([
205+
...model.enum.map((it) => [zod, `literal(${it})`].join(".")),
206+
`z.unknown().brand('unsupported enum value')`,
207+
]),
205208
]
206209
.filter(isDefined)
207210
.join(".")
@@ -230,7 +233,10 @@ export class ZodBuilder extends AbstractSchemaBuilder<
230233

231234
protected string(model: IRModelString) {
232235
if (model.enum) {
233-
return this.stringEnum(model)
236+
return this.union([
237+
this.stringEnum(model),
238+
`z.unknown().brand('unsupported enum value')`,
239+
])
234240
}
235241

236242
return [

packages/typescript-fetch-runtime/src/zod.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import type {z} from "zod"
1+
import {z} from "zod"
22
import {responseValidationFactoryFactory} from "./common"
33

4+
export const unknownEnumValue = z.unknown().brand("unknown enum value")
5+
export type UnknownEnumValue = z.infer<typeof unknownEnumValue>
6+
47
export function responseValidationFactory(
58
possibleResponses: [string, z.ZodTypeAny][],
69
defaultResponse?: z.ZodTypeAny,

0 commit comments

Comments
 (0)