Skip to content

Commit 5d57e41

Browse files
committed
Complete union and enum ergonomics
1 parent 1f70d8f commit 5d57e41

17 files changed

Lines changed: 1092 additions & 9 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ That is the core model of the library:
4646
- encode and decode come from the same definition
4747
- contract changes stay visible in one place
4848

49-
That same authored path also covers explicit tagged unions and recursive case trees through `Schema.union`, `Schema.unionNamed`, and `Schema.delay`.
49+
That same authored path also covers explicit tagged unions, string-valued enums, message envelopes, and recursive case trees through `Schema.union`, `Schema.inlineUnion`, `Schema.envelope`, `Schema.stringEnum`, and `Schema.delay`.
5050

5151
## Why use it
5252

TASKS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ Completed rename, parser, bridge, compatibility, JSON Schema, docs, and projecti
4848
- Add one configuration-loading guide that shows layered environment/file input, explicit defaults, startup validation, and friendly failure reporting.
4949
- Keep the examples grounded in the existing stable DSL rather than introducing framework-specific schema systems.
5050

51-
- [ ] **Task 39: Improve union and enum authoring ergonomics for app contracts**
51+
- [x] **Task 39: Improve union and enum authoring ergonomics for app contracts**
52+
- Shipped the terminology cleanup from `case0` / `case1` to `tag` / `tagWith`, and aligned the public/docs wording around tagged unions and `TaggedCase`.
53+
- Added `inlineUnion` / `inlineUnionNamed` for inline payload-member contracts, with readable docs plus malformed-input coverage.
54+
- Added `stringEnum` / `stringEnumNamed` as first-class string-valued contracts, including JSON Schema `enum` export.
55+
- Added `message`, `messageWith`, `envelope`, `envelopeNamed`, `inlineEnvelope`, and `inlineEnvelopeNamed` for common message and event contract shapes.
5256
- Add higher-level helpers for common string-enum, message-envelope, and public API union shapes so users do not have to hand-write projector/injector code for the most common cases.
5357
- Keep the explicit authored contract visible rather than hiding it behind reflection or attributes.
5458
- Cover JSON, XML, YAML, KeyValue, and JSON Schema export behavior for any new helpers.

docs/HOW_TO_EXPORT_JSON_SCHEMA.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ If you use `Schema.missingAsNone` inside a record field, the field is removed fr
7979
- objects with properties and required fields
8080
- nullable option shapes
8181
- authored tagged unions as `oneOf` object branches with `const` discriminators
82+
- authored inline tagged unions as `oneOf` object branches with merged payload properties
83+
- authored string enums as `"type":"string"` plus explicit `enum` values
8284
- recursive authored schemas through local `$defs` / `$ref` when the recursion is anchored with `Schema.delay`
8385
- mapped wrapper types as their underlying wire form
8486

@@ -108,6 +110,10 @@ let schemaText = JsonSchema.generate statusSchema
108110

109111
That exported schema uses one branch per case, with a `const` discriminator for the case name.
110112

113+
`Schema.inlineUnion` exports the same `oneOf` structure, but merges payload properties into the same object branch as the discriminator instead of nesting them under a separate payload field.
114+
115+
`Schema.stringEnum` exports as a string schema with an explicit `enum` list of allowed wire values.
116+
111117
## Export recursive authored schemas
112118

113119
When recursion is authored explicitly with `Schema.delay`, `JsonSchema.generate` exports a local definition and references it from recursive branches:

docs/HOW_TO_MODEL_A_RECURSIVE_TAGGED_UNION.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# How To Model A Recursive Tagged Union
22

3-
Use `Schema.union` when the JSON/XML/YAML/KeyValue wire shape should be an explicit tagged contract, and use `Schema.delay` when one of those cases needs to recurse back to the same schema.
3+
Use `Schema.union` when the JSON/XML/YAML/KeyValue wire shape should be an explicit tagged contract with a separate payload field. Use `Schema.inlineUnion` when payload members should sit next to the discriminator at the same level. Use `Schema.delay` when one of those tags needs to recurse back to the same schema.
44

55
This is the authored-schema path for recursive tree-like contracts. The wire contract stays explicit:
66

@@ -78,9 +78,11 @@ value.value.value=ok
7878
Use these helpers:
7979

8080
- `tag` for a tag without payload
81-
- `tagWith` for a tag with exactly one payload value
81+
- `tagWith` for a tag with one payload value
8282
- `union` for the default field names `"case"` and `"value"`
8383
- `unionNamed` when another system expects different field names
84+
- `inlineUnion` when payload members should be merged next to the discriminator
85+
- `inlineUnionNamed` when that inline shape needs a custom discriminator name
8486

8587
If you need the exact emitted JSON, XML, YAML, or KeyValue shapes, see [Tagged Union Wire Shape Reference](TAGGED_UNION_REFERENCE.md).
8688

docs/INTRODUCTION.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ That makes it useful when:
4848
- the wire shape matters and should stay reviewable
4949
- JSON and XML should stay symmetric
5050
- domain refinement should be explicit with `Schema.map` or `Schema.tryMap`
51-
- tagged unions and recursive case trees should stay explicit in normal schema code
51+
- tagged unions, inline tagged unions, and recursive case trees should stay explicit in normal schema code
52+
- common string enums and message envelopes should still read like authored contracts rather than serializer magic
5253
- AOT and Fable compatibility matter more than serializer magic
5354

5455
## The first path to learn

docs/TAGGED_UNION_REFERENCE.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ This page is for lookup once you already know the authored tagged-union API:
66
- `Schema.tagWith`
77
- `Schema.union`
88
- `Schema.unionNamed`
9+
- `Schema.inlineUnion`
10+
- `Schema.inlineUnionNamed`
11+
- `Schema.message`
12+
- `Schema.messageWith`
13+
- `Schema.envelope`
14+
- `Schema.envelopeNamed`
15+
- `Schema.inlineEnvelope`
16+
- `Schema.inlineEnvelopeNamed`
917
- `Schema.delay`
1018

1119
It describes the exact wire shapes currently emitted by the built-in codecs.
@@ -145,6 +153,117 @@ kind=failed
145153
details=boom
146154
```
147155

156+
## Inline payload fields with `inlineUnion`
157+
158+
`Schema.inlineUnion` keeps the discriminator field, but merges payload members
159+
into the same object level instead of nesting them under a separate payload
160+
field.
161+
162+
Example:
163+
164+
```fsharp
165+
type CreatedData = { Id: int; Name: string }
166+
type Event =
167+
| Ping
168+
| Created of CreatedData
169+
170+
let createdDataSchema =
171+
define<CreatedData>
172+
|> construct (fun id name -> { Id = id; Name = name })
173+
|> field "id" _.Id
174+
|> field "name" _.Name
175+
|> build
176+
177+
let eventSchema =
178+
inlineUnion [
179+
tag "ping" Ping ((=) Ping)
180+
tagWith
181+
"created"
182+
(function Created payload -> Some payload | _ -> None)
183+
Created
184+
createdDataSchema
185+
]
186+
```
187+
188+
JSON:
189+
190+
```json
191+
{"case":"created","id":7,"name":"Ada"}
192+
```
193+
194+
XML:
195+
196+
```xml
197+
<event><case>created</case><id>7</id><name>Ada</name></event>
198+
```
199+
200+
YAML:
201+
202+
```yaml
203+
case: created
204+
id: 7
205+
name: Ada
206+
```
207+
208+
KeyValue:
209+
210+
```text
211+
case=created
212+
id=7
213+
name=Ada
214+
```
215+
216+
Inline payload schemas must be object-shaped so the payload can contribute
217+
named members cleanly across all formats. Record schemas are the intended
218+
fit here.
219+
220+
## Custom discriminator names with `inlineUnionNamed`
221+
222+
`Schema.inlineUnionNamed discriminatorName` changes only the discriminator
223+
field name for the inline shape.
224+
225+
JSON:
226+
227+
```json
228+
{"kind":"created","id":7,"name":"Ada"}
229+
```
230+
231+
## Message and envelope helpers
232+
233+
For message and event contracts, the helper names can read more directly than
234+
the generic tagged-union names:
235+
236+
- `message` is the same authored tag shape as `tag`
237+
- `messageWith` is the same authored tag shape as `tagWith`
238+
- `envelope` uses `"type"` / `"data"` field names by default
239+
- `inlineEnvelope` uses `"type"` and inlines payload members next to it
240+
241+
Example envelope:
242+
243+
```fsharp
244+
let eventSchema =
245+
envelope [
246+
message "ping" Ping ((=) Ping)
247+
messageWith
248+
"created"
249+
(function Created payload -> Some payload | _ -> None)
250+
Created
251+
createdDataSchema
252+
]
253+
```
254+
255+
JSON:
256+
257+
```json
258+
{"type":"created","data":{"id":7,"name":"Ada"}}
259+
```
260+
261+
Inline envelope JSON:
262+
263+
```json
264+
{"type":"created","id":7,"name":"Ada"}
265+
```
266+
148267
## Recursive unions with `delay`
149268

150269
`Schema.delay` lets a union point back to itself:
@@ -186,6 +305,9 @@ The codecs currently reject:
186305
- unknown case names
187306
- missing payload fields for payload cases
188307
- stray payload keys for payload-free KeyValue cases
308+
- unknown inline case names
309+
- missing inline payload fields for payload tags
310+
- stray inline payload fields for payload-free tags
189311

190312
For KeyValue specifically, the payload-free case check matters because extra flattened keys would otherwise be easy to miss.
191313

src/CodecMapper/Json.fs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,23 @@ module Json =
829829
Decode = (fun src -> let struct (v, s) = Runtime.boolDecoder src in struct (box v, s))
830830
MissingValue = None
831831
}
832+
| StringEnum(_, tryGetName, parseName) ->
833+
{
834+
Encode =
835+
(fun w v ->
836+
match tryGetName v with
837+
| Some name ->
838+
w.WriteByte(34uy)
839+
w.WriteString(name)
840+
w.WriteByte(34uy)
841+
| None ->
842+
failwithf "No string enum name matched value for type %O" schema.TargetType)
843+
Decode =
844+
(fun src ->
845+
let struct (name, next) = Runtime.stringDecoder src
846+
struct (parseName name, next))
847+
MissingValue = None
848+
}
832849
| RawJsonValue ->
833850
let writeEscapedString (writer: IByteWriter) (value: string) =
834851
writer.WriteByte(34uy)
@@ -1329,6 +1346,134 @@ module Json =
13291346
| _ -> failwith "Expected union object")
13301347
MissingValue = None
13311348
}
1349+
| InlineUnion(discriminatorName, cases) ->
1350+
let compiledCases =
1351+
cases
1352+
|> Array.map (fun case -> {|
1353+
Case = case
1354+
Codec =
1355+
case.Schema
1356+
|> Option.map (fun payloadSchema ->
1357+
if not (Schema.supportsInlinePayloadShape payloadSchema) then
1358+
failwithf
1359+
"Inline union case '%s' payload schema must be object-shaped"
1360+
case.Name
1361+
1362+
loop payloadSchema)
1363+
|})
1364+
1365+
let rawJsonCodec = loop (Schema.jsonValue :> ISchema)
1366+
1367+
let encodeCaseName (writer: IByteWriter) (name: string) =
1368+
writer.WriteByte(34uy)
1369+
writer.WriteString(name)
1370+
writer.WriteByte(34uy)
1371+
1372+
let encodeInlinePayload (codec: CompiledCodec) (fieldValue: obj) =
1373+
let writer = ResizableBuffer.Create(defaultSerializeBufferCapacity)
1374+
1375+
try
1376+
codec.Encode writer fieldValue
1377+
1378+
let struct (rawPayload, rest) = rawJsonCodec.Decode(ByteSource(writer.InternalData, 0))
1379+
let rest = Runtime.skipWhitespace rest
1380+
1381+
if rest.Offset <> writer.InternalCount then
1382+
failwith "Inline union payload had trailing JSON content"
1383+
1384+
match unbox<JsonValue> rawPayload with
1385+
| JObject properties -> properties
1386+
| _ -> failwith "Inline union payload schema must encode as a JSON object"
1387+
finally
1388+
writer.Release()
1389+
1390+
let decodeInlinePayload (codec: CompiledCodec) (properties: (string * JsonValue) list) =
1391+
let payloadObject = JObject properties
1392+
let writer = ResizableBuffer.Create(defaultSerializeBufferCapacity)
1393+
1394+
try
1395+
rawJsonCodec.Encode writer (box payloadObject)
1396+
let struct (fieldValue, rest) = codec.Decode(ByteSource(writer.InternalData, 0))
1397+
let rest = Runtime.skipWhitespace rest
1398+
1399+
if rest.Offset <> writer.InternalCount then
1400+
failwith "Inline union payload had trailing JSON content"
1401+
1402+
fieldValue
1403+
finally
1404+
writer.Release()
1405+
1406+
{
1407+
Encode =
1408+
(fun writer value ->
1409+
match
1410+
compiledCases
1411+
|> Array.tryPick (fun compiled ->
1412+
compiled.Case.TryGetValue value
1413+
|> Option.map (fun fieldValue -> compiled, fieldValue))
1414+
with
1415+
| Some(compiled, fieldValue) ->
1416+
let payloadProperties =
1417+
match compiled.Codec with
1418+
| Some codec -> encodeInlinePayload codec fieldValue
1419+
| None -> []
1420+
1421+
writer.WriteByte(123uy)
1422+
encodeCaseName writer discriminatorName
1423+
writer.WriteByte(58uy)
1424+
encodeCaseName writer compiled.Case.Name
1425+
1426+
for propertyName, propertyValue in payloadProperties do
1427+
if propertyName = discriminatorName then
1428+
failwithf
1429+
"Inline union case '%s' payload cannot reuse discriminator field '%s'"
1430+
compiled.Case.Name
1431+
discriminatorName
1432+
1433+
writer.WriteByte(44uy)
1434+
encodeCaseName writer propertyName
1435+
writer.WriteByte(58uy)
1436+
rawJsonCodec.Encode writer (box propertyValue)
1437+
1438+
writer.WriteByte(125uy)
1439+
| None ->
1440+
failwithf "No union case matched value for type %O" schema.TargetType)
1441+
Decode =
1442+
(fun src ->
1443+
let struct (rawValue, next) = Runtime.jsonValueDecoder src
1444+
1445+
match rawValue with
1446+
| JObject properties ->
1447+
let tryFind name =
1448+
properties |> List.tryFind (fun (key, _) -> key = name) |> Option.map snd
1449+
1450+
let caseName =
1451+
match tryFind discriminatorName with
1452+
| Some(JString value) -> value
1453+
| Some _ -> failwithf "Union discriminator '%s' must be a string" discriminatorName
1454+
| None -> failwithf "Missing union discriminator '%s'" discriminatorName
1455+
1456+
let payloadProperties =
1457+
properties |> List.filter (fun (key, _) -> key <> discriminatorName)
1458+
1459+
match compiledCases |> Array.tryFind (fun compiled -> compiled.Case.Name = caseName) with
1460+
| Some compiled ->
1461+
match compiled.Codec with
1462+
| None ->
1463+
if List.isEmpty payloadProperties then
1464+
struct (compiled.Case.Construct None, next)
1465+
else
1466+
failwithf
1467+
"Union case '%s' does not accept payload fields alongside '%s'"
1468+
caseName
1469+
discriminatorName
1470+
| Some codec ->
1471+
let fieldValue = decodeInlinePayload codec payloadProperties
1472+
struct (compiled.Case.Construct(Some fieldValue), next)
1473+
| None -> failwithf "Unknown union case '%s'" caseName
1474+
| _ -> failwith "Expected union object")
1475+
MissingValue = None
1476+
}
13321477
| Delay factory -> loop (factory ())
13331478
| List innerSchema ->
13341479
let innerCodec = loop innerSchema

0 commit comments

Comments
 (0)