json.md

April 29, 2025 · View on GitHub

JSON serialisation

alloy defines a number of traits that can be taken into consideration by protocols to express additional constraints and encodings typically found in the industry.

Unions

Unions in this protocol can be encoded in three different ways: tagged, discriminated, and untagged.

By default, the specification of the Smithy language hints that the tagged-union encoding should be used. This is arguably the best encoding for unions, as it works with members of any type (not just structures), and does not require backtracking during parsing, which makes it more efficient.

However, alloy#simpleRestJson supports two additional encodings: discriminated and untagged, which users can opt-in via the alloy#discriminated and alloy#untagged trait, respectively. These are mostly offered as a way to retrofit existing APIs in Smithy.

Tagged union

This is the default behavior, and happens to visually match how Smithy unions are declared. In this encoding, the union is encoded as a JSON object with a single key-value pair, the key signalling which alternative has been encoded.

union Tagged {
  first: String
  second: IntWrapper
}

structure IntWrapper {
  int: Integer
}

The following instances of Tagged

Tagged.FirstCase("alloy")
Tagged.SecondCase(IntWrapper(42)))

are encoded as such :

{ "first": "alloy" }
{ "second": { "int": 42 } }

Untagged union

Untagged unions are supported via an annotation: @untagged. Despite the smaller payload size this encoding produces, it is arguably the worst way of encoding unions, as it may require backtracking multiple times on the parsing side. Use this carefully, preferably only when you need to retrofit an existing API into Smithy.

use alloy#untagged

@untagged
union Untagged {
  first: String
  second: IntWrapper
}

structure IntWrapper {
  int: Integer
}

The following instances of Untagged

Untagged.FirstCase("alloy")
Untagged.SecondCase(Two(42)))

are encoded as such :

"alloy"
{ "int": 42 }

Discriminated union

Discriminated union are supported via an annotation: @discriminated("tpe"), and work only when all members of the union are structures. In this encoding, the discriminator is inlined as a JSON field within JSON object resulting from the encoding of the member.

Despite the JSON payload exhibiting less nesting than in the tagged union encoding, this encoding often leads to bigger payloads, and requires backtracking once during parsing.

use alloy#discriminated

@discriminated("tpe")
union Discriminated {
  first: StringWrapper
  second: IntWrapper
}

structure StringWrapper {
  myString: String
}

structure IntWrapper {
  myInt: Integer
}

The following instances of Discriminated

Discriminated.FirstCase(StringWrapper("alloy"))
Discriminated.SecondCase(IntWrapper(42)))

are encoded as such

{ "tpe": "first", "myString": "alloy" }
{ "tpe": "second", "myInt": 42 }

Null values

The standard Smithy toolset does not provide any semantics for distinguishing between a JSON field being set to null and the same field being absent from its carrying JSON object. However, depending on the use-case, the difference can be meaningful. In order to support such use-cases, the additional trait alloy.nullable is provided. Annotating the member of a structure field with this indicates that a value serialised to null was a conscious decision (as opposed to omitting the value altogether), and that deserialisation should retain this information.

For example, assuming the following smithy structure

use alloy#nullable

structure Foo {
  @nullable
  nullable: Integer
  regular: Integer
}

The JSON objects

{ "nullable": null, "regular": null }
{ "nullable": 4, "regular": 4 }
{}

are respectively decoded as follows in Scala (when using smithy4s):

Foo(Some(Nullable.Null), None)
Foo(Some(Nullable.Value(4)), Some(4))
Foo(None, None)

or some similar type which preserves the information that an explicit null was passed. These objects are in turn encoded as

{ "nullable": null }
{ "nullable": 4, "regular": 4 }
{}

This means that @nullable allows round-tripping null values.

Unknown fields

Retaining JSON fields whose label do not match structure member names is supported via the @jsonUnknown Smithy trait. This trait can be applied to a single structure member targeting a map with document values.

JSON decoders supporting this trait must store unknown properties in the annotated map. Symmetrically, JSON encoders must inline the values from the map in the JSON object produced when serializing the enclosing structure.

Note that if a JSON document contains a field using the same label as the member annotated with the @jsonUnknown trait, it will be treated as an unknown field.

For example, given the following smithy definitions

use alloy#jsonUnknown

structure Data {
  known: String
  @jsonUnknown
  unknown: UnknownProperties
}

map UnknownProperties {
  key: String
  value: Document
}

The JSON objects

{ "known": "known value" }
{ "known": "known value", "aField": 1, "anotherField": "another value" }
{ "known": "known value", "unknown": 1 }

are respectively decoded as follows in Scala (when using smithy4s)

Data(known=Some("known value"), unknown=None)
Data(known=Some("known value"),
     unknown=Some(Map("aField" -> Document.DNumber(1), "anotherField" -> Document.DString("another value"))))
Data(known=Some("known value"), unknown=Some(Map("unknown" -> Document.DNumber(1))))

Open unions

It is also possible to retain union members whose tag/discriminator doesn't match any of the known ones. This is also done by applying the @jsonUnknown Smithy trait, to a union member targetting a document shape.

JSON decoders supporting this trait must store the entire union payload in the annotated document. Likewise, JSON encoders must write back the entire content of the document when re-serializing the unknown payload.

If the union fails to decode for other reasons, such as a missing tag (in case of the default, tagged unions) or missing discriminator key (in case of @discriminated unions), that will still be considered a decoding failure. The catch-all member only gets filled in if the tag/discriminator exists, but doesn't match any known alternative.

Note that if the JSON document contains a tag/discriminator matching the name of the member annotated with @jsonUnknown, it'll still be treated as an unknown tag.

For example, given the following Smithy definitions:

use alloy#jsonUnknown

union Data {
  string: String
  @jsonUnknown other: Document
}

The JSON objects

{"string": "known value"}
{"unknown": 42}
{"other": {"string": "some string"}}

are respectively decoded as follows in Scala (using smithy4s)

Data.string("known value")
Data.other(Document.obj("unknown" -> Document.fromInt(42)))
Data.other(Document.obj("other" -> Document.obj("string" -> Document.fromString("some string"))))

In case of discriminated unions:

use alloy#jsonUnknown

@discriminated("type")
union Data {
  struct: Unit
  @jsonUnknown other: Document
}

The JSON objects

{"type": "struct"}
{"type": "other"}
{"type": "other", "k": 42}

are respectively decoded as follows:

Data.struct
Data.other(Document.obj("type" -> Document.fromString("other")))
Data.other(Document.obj("type" -> Document.fromString("other"), "k" -> Document.fromInt(42)))