From c2ee6b4bfb436c9f3122e448623dd5c62e360e80 Mon Sep 17 00:00:00 2001 From: Peter Kotula Date: Wed, 19 Aug 2026 22:16:41 +0200 Subject: [PATCH] support for Schema.Enum --- src/Http.ts | 3 + src/WitCodec.ts | 37 +++++++ test/wit-codec-enum.test.ts | 200 +++++++++++++++++++++++++++++++++++ test/wit-codec-union.test.ts | 58 ++++++++++ 4 files changed, 298 insertions(+) create mode 100644 test/wit-codec-enum.test.ts diff --git a/src/Http.ts b/src/Http.ts index 2e7ff06..69a480f 100644 --- a/src/Http.ts +++ b/src/Http.ts @@ -1800,6 +1800,7 @@ const isCollectionElementBindableAst = (ast: SchemaAST.AST): boolean => { case "BigInt": case "Boolean": case "TemplateLiteral": + case "Enum": return true case "Literal": { const literal = (ast as SchemaAST.Literal).literal @@ -1845,6 +1846,8 @@ const isStringBindableAst = (ast: SchemaAST.AST): boolean => { // string/number/boolean/bigint literals are all valid in URL // contexts; null literal also serialisable. return true + case "Enum": + return true case "TemplateLiteral": return true case "UniqueSymbol": diff --git a/src/WitCodec.ts b/src/WitCodec.ts index fe8ea90..529b538 100644 --- a/src/WitCodec.ts +++ b/src/WitCodec.ts @@ -225,6 +225,16 @@ const encodedShapeOf = (a: SchemaAST.AST): EncodedShape => { matches: (v) => typeof v === "object" && v !== null && !Array.isArray(v), } } + case "Enum": { + const values = (a as SchemaAST.Enum).enums.map(([, v]) => v) + const valuesSet = new Set(values) + const allStrings = values.every((v) => typeof v === "string") + const allNumbers = values.every((v) => typeof v === "number") + return { + tag: allStrings ? "string" : allNumbers ? "number" : "unknown", + matches: (v) => valuesSet.has(v), + } + } default: return { tag: "unknown", matches: () => true } } @@ -610,6 +620,33 @@ const walk = ( return yield* declarationNode(a) } + case "Enum": { + const astEnum = a as SchemaAST.Enum + if (astEnum.enums.length === 0) { + return yield* unsupported("empty enum") + } + const allStrings = astEnum.enums.every(([, v]) => typeof v === "string") + if (allStrings) { + const literals = astEnum.enums.map(([, v]) => v as string) + return { + node: { tag: "enum-type", val: literals }, + pair: { + toTree: (s: string) => ({ tag: "enum-value", val: literals.indexOf(s) }), + fromTree: (t) => literals[(t as { val: number }).val]!, + }, + } + } + const names = astEnum.enums.map(([name]) => name) + const values = astEnum.enums.map(([, v]) => v) + return { + node: { tag: "enum-type", val: names }, + pair: { + toTree: (v: unknown) => ({ tag: "enum-value", val: values.indexOf(v as any) }), + fromTree: (t) => values[(t as { val: number }).val]!, + }, + } + } + default: return yield* unsupported(`unsupported AST node: ${a._tag}`) } diff --git a/test/wit-codec-enum.test.ts b/test/wit-codec-enum.test.ts new file mode 100644 index 0000000..9919de8 --- /dev/null +++ b/test/wit-codec-enum.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Schema } from "effect" +import { defineAgent, Http, method } from "../src/index.js" +import { toWitCodec } from "../src/WitCodec.js" + +enum UserConnectionType { + Friend = "Friend", + Follower = "Follower", + Following = "Following", +} + +enum LikeType { + Like = "like", + Insightful = "insightful", + Love = "love", + Dislike = "dislike", +} + +enum Status { + Pending = 0, + Active = 1, + Completed = 2, +} + +enum HttpCode { + Ok = 200, + NotFound = 404, +} + +describe("toWitCodec with Schema.Enum", () => { + it.effect("round-trips string enum where keys equal values", () => + Effect.gen(function* () { + const SchemaEnum = Schema.Enum(UserConnectionType) + const wc = yield* toWitCodec(SchemaEnum) + + expect(wc.witType.nodes[0]?.type).toEqual({ + tag: "enum-type", + val: ["Friend", "Follower", "Following"], + }) + + for (const val of [ + UserConnectionType.Friend, + UserConnectionType.Follower, + UserConnectionType.Following, + ]) { + const wv = yield* Schema.encodeEffect(wc.codec)(val) + const back = yield* Schema.decodeEffect(wc.codec)(wv) + expect(back).toBe(val) + } + }), + ) + + it.effect("round-trips string enum where keys differ from values", () => + Effect.gen(function* () { + const SchemaEnum = Schema.Enum(LikeType) + const wc = yield* toWitCodec(SchemaEnum) + + expect(wc.witType.nodes[0]?.type).toEqual({ + tag: "enum-type", + val: ["like", "insightful", "love", "dislike"], + }) + + for (const val of [LikeType.Like, LikeType.Insightful, LikeType.Love, LikeType.Dislike]) { + const wv = yield* Schema.encodeEffect(wc.codec)(val) + const back = yield* Schema.decodeEffect(wc.codec)(wv) + expect(back).toBe(val) + } + }), + ) + + it.effect("round-trips numeric enum", () => + Effect.gen(function* () { + const SchemaEnum = Schema.Enum(Status) + const wc = yield* toWitCodec(SchemaEnum) + + expect(wc.witType.nodes[0]?.type).toEqual({ + tag: "enum-type", + val: ["Pending", "Active", "Completed"], + }) + + for (const val of [Status.Pending, Status.Active, Status.Completed]) { + const wv = yield* Schema.encodeEffect(wc.codec)(val) + const back = yield* Schema.decodeEffect(wc.codec)(wv) + expect(back).toBe(val) + } + }), + ) + + it.effect("round-trips non-sequential numeric enum", () => + Effect.gen(function* () { + const SchemaEnum = Schema.Enum(HttpCode) + const wc = yield* toWitCodec(SchemaEnum) + + expect(wc.witType.nodes[0]?.type).toEqual({ + tag: "enum-type", + val: ["Ok", "NotFound"], + }) + + for (const val of [HttpCode.Ok, HttpCode.NotFound]) { + const wv = yield* Schema.encodeEffect(wc.codec)(val) + const back = yield* Schema.decodeEffect(wc.codec)(wv) + expect(back).toBe(val) + } + }), + ) + + it.effect("round-trips Schema.Struct containing Schema.Enum", () => + Effect.gen(function* () { + const Payload = Schema.Struct({ + conn: Schema.Enum(UserConnectionType), + status: Schema.Enum(Status), + message: Schema.String, + }) + const wc = yield* toWitCodec(Payload) + + const val = { + conn: UserConnectionType.Follower, + status: Status.Active, + message: "hello", + } + const wv = yield* Schema.encodeEffect(wc.codec)(val) + const back = yield* Schema.decodeEffect(wc.codec)(wv) + expect(back).toEqual(val) + }), + ) + + it.effect("round-trips Schema.Array of Schema.Enum", () => + Effect.gen(function* () { + const List = Schema.Array(Schema.Enum(LikeType)) + const wc = yield* toWitCodec(List) + + const val = [LikeType.Like, LikeType.Love, LikeType.Like] + const wv = yield* Schema.encodeEffect(wc.codec)(val) + const back = yield* Schema.decodeEffect(wc.codec)(wv) + expect(back).toEqual(val) + }), + ) + + it.effect("round-trips Schema.NullOr(Schema.Enum)", () => + Effect.gen(function* () { + const Nullable = Schema.NullOr(Schema.Enum(UserConnectionType)) + const wc = yield* toWitCodec(Nullable) + + expect(wc.witType.nodes[0]?.type.tag).toBe("option-type") + + const val1 = UserConnectionType.Friend + const wv1 = yield* Schema.encodeEffect(wc.codec)(val1) + const back1 = yield* Schema.decodeEffect(wc.codec)(wv1) + expect(back1).toBe(val1) + + const val2 = null + const wv2 = yield* Schema.encodeEffect(wc.codec)(val2) + const back2 = yield* Schema.decodeEffect(wc.codec)(wv2) + expect(back2).toBeNull() + }), + ) + + it.effect("round-trips Schema.Union containing Schema.Enum and primitive", () => + Effect.gen(function* () { + const UnionSchema = Schema.Union([Schema.Enum(UserConnectionType), Schema.Number]) + const wc = yield* toWitCodec(UnionSchema) + + expect(wc.witType.nodes[0]?.type.tag).toBe("variant-type") + + const val1 = UserConnectionType.Following + const wv1 = yield* Schema.encodeEffect(wc.codec)(val1) + const back1 = yield* Schema.decodeEffect(wc.codec)(wv1) + expect(back1).toBe(val1) + + const val2 = 42 + const wv2 = yield* Schema.encodeEffect(wc.codec)(val2) + const back2 = yield* Schema.decodeEffect(wc.codec)(wv2) + expect(back2).toBe(val2) + }), + ) + + it("works in defineAgent with HTTP endpoint bindings", () => { + const AgentDef = defineAgent({ + name: "EnumAgent", + constructorParams: { name: Schema.String }, + http: Http.mount("/enum-agent/{name}"), + methods: { + getConnection: method({ + params: { type: Schema.Enum(UserConnectionType) }, + success: Schema.Enum(UserConnectionType), + http: [Http.get("/connection?type={type}")], + }), + getStatus: method({ + params: { status: Schema.Enum(Status) }, + success: Schema.Enum(Status), + http: [Http.get("/status/{status}")], + }), + }, + }) + + expect(AgentDef.name).toBe("EnumAgent") + expect(AgentDef.methods.getConnection).toBeDefined() + expect(AgentDef.methods.getStatus).toBeDefined() + }) +}) diff --git a/test/wit-codec-union.test.ts b/test/wit-codec-union.test.ts index 8340ad1..b2aabf3 100644 --- a/test/wit-codec-union.test.ts +++ b/test/wit-codec-union.test.ts @@ -223,4 +223,62 @@ describe("Schema.Union → WIT variant", () => { expect(b.back).toEqual({ _tag: "named", name: "x" }) }), ) + + it.effect("string-literal union (Schema.Literals) emits enum-type and round-trips", () => + Effect.gen(function* () { + const Color = Schema.Literals(["red", "green", "blue"]) + const wc = yield* compile(Color) + expect(wc.witType.nodes[0]?.type).toEqual({ + tag: "enum-type", + val: ["red", "green", "blue"], + }) + for (const c of ["red", "green", "blue"] as const) { + const r = yield* roundtrip(Color, c) + expect(r.back).toBe(c) + expect((r.wv.nodes[0] as any).tag).toBe("enum-value") + } + }), + ) + + it.effect("Schema.Union of Schema.Literal strings emits enum-type and round-trips", () => + Effect.gen(function* () { + const Fruit = Schema.Union([Schema.Literal("apple"), Schema.Literal("banana")]) + const wc = yield* compile(Fruit) + expect(wc.witType.nodes[0]?.type).toEqual({ + tag: "enum-type", + val: ["apple", "banana"], + }) + for (const f of ["apple", "banana"] as const) { + const r = yield* roundtrip(Fruit, f) + expect(r.back).toBe(f) + expect((r.wv.nodes[0] as any).tag).toBe("enum-value") + } + }), + ) + + it.effect("single string literal emits prim-string-type and round-trips", () => + Effect.gen(function* () { + const Only = Schema.Literal("constant") + const wc = yield* compile(Only) + expect(wc.witType.nodes[0]?.type).toEqual({ + tag: "prim-string-type", + }) + const r = yield* roundtrip(Only, "constant") + expect(r.back).toBe("constant") + expect((r.wv.nodes[0] as any).tag).toBe("prim-string") + }), + ) + + it.effect("single numeric literal emits prim-f64-type and round-trips", () => + Effect.gen(function* () { + const FortyTwo = Schema.Literal(42) + const wc = yield* compile(FortyTwo) + expect(wc.witType.nodes[0]?.type).toEqual({ + tag: "prim-f64-type", + }) + const r = yield* roundtrip(FortyTwo, 42) + expect(r.back).toBe(42) + expect((r.wv.nodes[0] as any).tag).toBe("prim-float64") + }), + ) })