title: Xlang Serialization Format sidebar_position: 0 id: xlang_serialization_spec license: | Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Apache Fory™ xlang serialization enables automatic cross-language object serialization with support for shared references, circular references, and polymorphism. Unlike traditional serialization frameworks that require IDL definitions and schema compilation, Fory serializes objects directly without any intermediate steps.
Key characteristics:
This specification defines the Fory xlang binary format. The format is dynamic rather than static, which enables flexibility and ease of use at the cost of additional complexity in the wire format.
List<SomeClass>, we can save dynamic serializer dispatch since SomeClass is dynamic(final).struct whose type mapping will be encoded as a name.compatible_struct whose type mapping will be encoded as a name.ext type whose type mapping will be encoded as a name.list, map, set, and array.scale and an exact unscaled integer.array<T> is dense one-dimensional bool or numeric data. Current xlang emits the canonical specialized *_ARRAY type IDs for each supported element domain. ARRAY (42) is reserved for a future generic array encoding and is not emitted by the current xlang format. list<T> remains a separate schema kind.array<bool>.array<int8>.array<int16>.array<int32>.array<int64>.array<uint8>.array<uint16>.array<uint32>.array<uint64>.array<float8>.array<float16>.array<bfloat16>.array<float32>.array<float64>.Note:
A language binding MAY separate the host type that supplies serialization behavior from the host value type being serialized. That separation is not a wire identity.
FieldType shape, type metadata, reference framing, and value bytes as the corresponding directly supported composition. The carrier serializer is not a user wire identity; only selected user-type children use their registered IDs or names.i32 serializer uses INT32_ARRAY, one over the canonical u8 serializer uses BINARY, and one over an external structural or custom serializer uses LIST. A nested carrier MUST preserve the selected child type ID and recursive FieldType; serializer composition MUST NOT replace a canonical primitive-array or binary mapping with LIST. Conversely, a Swift Array carrier serializer MUST remain LIST because that is Swift's canonical statically selected Array mapping. Swift dense @ArrayField and dynamic exact primitive-array mappings are separate canonical selections; a serializer whose target happens to be numeric does not acquire either mapping.For polymorphism, if one non-final class is registered, and only one subclass is registered, then we can take all elements in List/Map have same type, thus reduce per-element type checks.
Collection/Array polymorphism are not fully supported, since some languages such as golang have only one collection type. If users want to get exactly the type he passed, he must pass that type when deserializing or annotate that type to the field of struct.
Due to differences between type systems of languages, those types can't be mapped one-to-one between languages. When deserializing, Fory use the target data structure type and the data type in the data jointly to determine how to deserialize and populate the target data structure. For example:
class Foo { int[] intArray; Object[] objects; List<Object> objectList; } class Foo2 { int[] intArray; List<Object> objects; List<Object> objectList; }
intArray has array<int32> schema and uses the int32_array wire tag. Both objects and objectList have list schema. These schema kinds are distinct; implementations must not treat general object arrays as dense numeric arrays.
list<T> and array<T> are different schema kinds.
Use list<T> for ordinary ordered collections whose elements may need collection semantics, nullable element handling, reference handling, or object/string/bytes payloads. A primitive list<T> may still use an optimized homogeneous element segment internally, but the payload is owned by the list protocol and carries list metadata.
Use array<T> for dynamic-length dense one-dimensional bool or numeric data. array<T> elements are always non-null, non-reference-tracked, and fixed-width by the array contract. array<bool> uses one byte per value. Integer arrays use fixed-width little-endian element payloads even when the scalar int32, int64, uint32, or uint64 default encoding is varint/PVL in scalar or list positions.
Valid array<T> element domains are:
bool int8, int16, int32, int64 uint8, uint16, uint32, uint64 float16, bfloat16, float32, float64
Invalid array schemas include array<fixed int32>, array<optional int32>, array<ref T>, array<string>, array<bytes>, array<map<...>>, and arrays of structs, unions, enums, temporal values, decimals, or dynamic any values.
The current wire format keeps specialized primitive-array type IDs as the canonical dynamic tags for array<T>:
| Schema | Dynamic wire tag |
|---|---|
array<bool> | BOOL_ARRAY |
array<int8> | INT8_ARRAY |
array<int16> | INT16_ARRAY |
array<int32> | INT32_ARRAY |
array<int64> | INT64_ARRAY |
array<uint8> | UINT8_ARRAY |
array<uint16> | UINT16_ARRAY |
array<uint32> | UINT32_ARRAY |
array<uint64> | UINT64_ARRAY |
array<float16> | FLOAT16_ARRAY |
array<bfloat16> | BFLOAT16_ARRAY |
array<float32> | FLOAT32_ARRAY |
array<float64> | FLOAT64_ARRAY |
ARRAY (42) is reserved for a future generic or shaped-array descriptor and is not emitted for dense primitive arrays.
In schema-compatible mode only, a matched struct/class field may read between direct top-level list<T> and direct top-level array<T> schemas when T belongs to the valid dense array element domains above. Integer list element encodings in the same signedness and width domain match the corresponding dense array element domain. This is a read adaptation, not a schema-kind merge: writers keep emitting their local canonical list<T> or array<T> payload, and TypeDef/ClassDef encodings, fingerprints, dynamic root serialization, same-schema mode, and unknown-field skipping continue to treat list<T> and array<T> as distinct kinds.
The adaptation is limited to the immediate schema of the matched compatible field. It does not apply when list<T> or array<T> appears inside another field type, including collection elements, map keys or values, array elements, union alternatives, or other generic/container positions. A peer list<T?> TypeDef element schema is not immediate schema incompatibility for a local matched array<T> field. Classification must accept the matched field when the element domains match and the only element-schema difference is nullable metadata. The reader must decide from the collection payload: if the payload actually carries a null element, the local array<T> field must raise a compatible-read error. Null list elements must not be coerced to dense-array default values. Reference-tracked list-element framing is separate from nullable element schema. A runtime that cannot materialize ref-tracked list elements into a dense array without generic/reference paths may reject that field during compatible classification; if it accepts the field, reference payloads that cannot be represented as dense array element values must fail during read.
The dense-array error rule applies to dense-array targets. A matched list<T?> field read into a local list<T?> target must keep using list semantics and preserve actual null elements; implementations must not route that payload through a dense primitive-array materialization path that rejects nulls.
In schema-compatible mode only, a matched struct/class field may read between direct top-level binary and direct top-level array<uint8> schemas. This is a byte-sequence adaptation only: it does not merge TypeDef/ClassDef type IDs, schema fingerprints, dynamic root serialization, same-schema mode, or nested collection/map/array/union/generic positions. array<int8> is not part of this adapter.
In schema-compatible mode only, a matched struct/class field may also read between direct top-level scalar schemas when the remote value can be represented by the local scalar schema without changing the logical value. This is a compatible read adaptation only: writers keep emitting their local canonical schema and payload, and TypeDef/ClassDef encodings, fingerprints, dynamic root serialization, same-schema mode, unknown-field skipping, and container element schemas continue to treat the original scalar types as distinct.
The scalar conversion rule applies only to the immediate schema of the matched compatible field. It does not apply to dynamic root values, any, map keys, map values, list elements, set elements, array elements, union alternatives, enum values, time/date/duration values, binary values, structs, ext values, or nested generic/container positions. It also applies only when both the remote and local top-level field schemas have trackingRef = false; if either matched field schema has trackingRef = true, scalar conversion is outside the compatible layout matrix and scalar type changes remain schema/type incompatible. Same scalar type IDs with matching top-level trackingRef and null/optional framing are exact same-schema direct reads, not compatible scalar conversion. Same scalar type IDs with different top-level trackingRef framing are schema/type incompatible because the wire framing differs. Same scalar type IDs with different top-level null/optional framing may still use the nullable/optional composition rule below when both fields have trackingRef = false.
The convertible scalar domains are bool, string, and numeric scalars. Numeric scalars are signed integers (int8, int16, int32, int64), unsigned integers (uint8, uint16, uint32, uint64), floating point (float16, bfloat16, float32, float64), and decimal. Integer encoding variants are the same semantic domain as their base width: fixed, variable, and tagged integer encodings do not create additional conversion domains.
Compatible scalar conversion MUST follow these rules:
string to bool accepts exactly "0", "1", "false", and "true". The match is byte-for-byte ASCII; readers MUST NOT trim whitespace, accept a leading sign, accept other letter case, or use locale-specific text.bool to string produces canonical lower-case "false" or "true".bool accepts only exact numeric zero and exact numeric one. NaN and infinities fail. Negative floating zero is zero. Decimal scale does not affect the zero/one check.bool to numeric produces exact zero or one in the local numeric domain.NaN is not convertible across different floating type IDs.0; decimal-to-integer conversion requires an integral value in range; floating-to-decimal conversion requires a finite value and converts the exact binary floating value to canonical decimal form; decimal-to-floating conversion requires exact representability in the target floating domain. Same-type decimal reads preserve the ordinary decimal payload. Decimal values produced by conversion use the canonical converted decimal form below.string to numeric accepts only the compatible numeric literal grammar below and then applies the same lossless target-domain checks. "NaN", "Infinity", "-Infinity", and spelling variants fail because numeric strings are finite-only.string emits canonical finite numeric text. Integer sources emit decimal text with no leading zeros except "0". Floating sources emit exact plain decimal text that equals the source value and parses back to the same source floating type; it includes a decimal point and at least one fractional digit, preserves negative zero as "-0.0", never uses exponent notation, and fails for NaN and infinities. Decimal sources emit exact plain decimal text with no exponent and no insignificant trailing fractional zeros; decimal zero is "0".The compatible numeric literal grammar is deliberately stricter than host language parsers:
-, 0 through 9, ., e, and E;-?(0|[1-9][0-9]*);-?(0|[1-9][0-9]*)\.[0-9]+([eE]-?(0|[1-9][0-9]*))? or -?(0|[1-9][0-9]*)[eE]-?(0|[1-9][0-9]*).Readers MUST parse numeric strings with exact decimal, rational, or equivalent checked algorithms. Parsing through a host floating type and then casting is not valid unless the implementation also proves exactness against the original literal.
Canonical converted decimal form is:
unscaled = 0, scale = 0;scale = 0 and the integer as unscaled;unscaled * 10^-scale equals the value and whose unscaled is not divisible by 10.Compatible scalar conversion MUST reject a numeric string before arbitrary precision parsing when the raw string length is greater than 320. It MUST also reject a converted decimal before constructing large powers of ten or formatting plain decimal text when its canonical converted form would require an exponent or scale outside [-256, 256], a positive scale greater than 256, an unscaled decimal magnitude with more than 256 significant digits, or a negative scale whose formatted integer digit count would exceed 256. These bounds apply only to values produced by compatible scalar conversion, including string-to-decimal, decimal-to-string, and floating-to-decimal conversion. Same-type decimal reads preserve the ordinary decimal payload. A bounded public decimal carrier may reject smaller values when it cannot represent the value exactly.
Nullable, boxed, optional, and nullable-field composition is supported for matched scalar pairs whose top-level field schemas have trackingRef = false. Readers first consume the remote null/optional framing described by the remote field metadata. If a value is present, the reader converts the unwrapped scalar value and then assigns or wraps it into the local carrier. If the remote value is null or absent, the reader uses the same missing/null compatible-field rule it already applies for that local field; this feature does not introduce a second null policy. Reference-tracked scalar conversion is not supported.
Conversion failures are data errors, not schema misses. A schema pair outside the conversion matrix remains a schema/type compatibility error when building the compatible layout. Once a matched field is accepted as a scalar conversion action, an invalid payload value MUST be reported through the implementation's data-error path with enough context to identify the remote type, local type, and field when that path has the information.
Unknown-field skipping applies only when the remote field has no matching local field identity. If a local field matches by tag ID or name but its schema is outside the exact-read and compatible-adaptation rules, the reader MUST reject the compatible layout instead of treating the field as missing, remote-only, or skippable.
Users can also provide meta hints for fields of a type, or the type whole. Here is an example in java which use annotation to provide such information.
@ForyStruct class Foo { @ArrayType @ForyField(id = 0) int[] intArray; @ForyField(id = 1, dynamic = ForyField.Dynamic.TRUE) Object object; @Nullable @ForyField(id = 2) List<Object> objectList; }
Such information can be provided in other languages too:
All internal data types use an 8-bit internal ID (0~255, with 0~56 defined here). Users can register types by numeric ID (0~0xFFFFFFFE in current implementations). User IDs are encoded separately from the internal type ID; there is no bit shifting/packing.
Named types (NAMED_*) do not embed a user ID; their names are carried in metadata instead.
| Type ID | Name | Description |
|---|---|---|
| 0 | UNKNOWN | Unknown type, used for dynamic typing |
| 1 | BOOL | Boolean value |
| 2 | INT8 | 8-bit signed integer |
| 3 | INT16 | 16-bit signed integer |
| 4 | INT32 | 32-bit signed integer |
| 5 | VARINT32 | Variable-length encoded 32-bit signed integer |
| 6 | INT64 | 64-bit signed integer |
| 7 | VARINT64 | Variable-length encoded 64-bit signed integer |
| 8 | TAGGED_INT64 | Hybrid encoded 64-bit signed integer |
| 9 | UINT8 | 8-bit unsigned integer |
| 10 | UINT16 | 16-bit unsigned integer |
| 11 | UINT32 | 32-bit unsigned integer |
| 12 | VAR_UINT32 | Variable-length encoded 32-bit unsigned integer |
| 13 | UINT64 | 64-bit unsigned integer |
| 14 | VAR_UINT64 | Variable-length encoded 64-bit unsigned integer |
| 15 | TAGGED_UINT64 | Hybrid encoded 64-bit unsigned integer |
| 16 | FLOAT8 | 8-bit floating point (float8) |
| 17 | FLOAT16 | 16-bit floating point (half precision) |
| 18 | BFLOAT16 | 16-bit brain floating point |
| 19 | FLOAT32 | 32-bit floating point (single precision) |
| 20 | FLOAT64 | 64-bit floating point (double precision) |
| 21 | STRING | UTF-8/UTF-16/Latin1 encoded string |
| 22 | LIST | Ordered collection (List, Array, Vector) |
| 23 | SET | Unordered collection of unique elements |
| 24 | MAP | Key-value mapping |
| 25 | ENUM | Enum registered by numeric ID |
| 26 | NAMED_ENUM | Enum registered by namespace + type name |
| 27 | STRUCT | Struct registered by numeric ID (same-schema) |
| 28 | COMPATIBLE_STRUCT | Struct with schema evolution support (by ID) |
| 29 | NAMED_STRUCT | Struct registered by namespace + type name |
| 30 | NAMED_COMPATIBLE_STRUCT | Struct with schema evolution (by name) |
| 31 | EXT | Extension type registered by numeric ID |
| 32 | NAMED_EXT | Extension type registered by namespace + type name |
| 33 | UNION | Union value, schema identity not embedded |
| 34 | TYPED_UNION | Union value with registered numeric type ID |
| 35 | NAMED_UNION | Union value with embedded type name/TypeDef |
| 36 | NONE | Empty/unit type (no data) |
| 37 | DURATION | Time duration (seconds + nanoseconds) |
| 38 | TIMESTAMP | Point in time (seconds + nanoseconds since epoch) |
| 39 | DATE | Date without timezone (signed varint64 days) |
| 40 | DECIMAL | Arbitrary precision decimal (scale + unscaled) |
| 41 | BINARY | Raw binary data |
| 42 | ARRAY | Reserved for future dedicated multi-dimensional arrays |
| 43 | BOOL_ARRAY | 1D boolean array |
| 44 | INT8_ARRAY | 1D int8 array |
| 45 | INT16_ARRAY | 1D int16 array |
| 46 | INT32_ARRAY | 1D int32 array |
| 47 | INT64_ARRAY | 1D int64 array |
| 48 | UINT8_ARRAY | 1D uint8 array |
| 49 | UINT16_ARRAY | 1D uint16 array |
| 50 | UINT32_ARRAY | 1D uint32 array |
| 51 | UINT64_ARRAY | 1D uint64 array |
| 52 | FLOAT8_ARRAY | 1D float8 array |
| 53 | FLOAT16_ARRAY | 1D float16 array |
| 54 | BFLOAT16_ARRAY | 1D bfloat16 array |
| 55 | FLOAT32_ARRAY | 1D float32 array |
| 56 | FLOAT64_ARRAY | 1D float64 array |
When registering user types (struct/ext/enum/union), the internal type ID is written as the 8-bit kind. The user type ID is written separately as an unsigned varint32 (small7); there is no bit shift or packing.
Examples:
| User ID | Type | Internal ID | Encoded User ID | Decimal |
|---|---|---|---|---|
| 0 | STRUCT | 27 | 0 | 0 |
| 0 | ENUM | 25 | 0 | 0 |
| 1 | STRUCT | 27 | 1 | 1 |
| 1 | COMPATIBLE_STRUCT | 28 | 1 | 1 |
| 2 | NAMED_STRUCT | 29 | 2 | 2 |
When reading type IDs:
user_type_id as varuint32.See Type mapping
Here is the overall format:
| fory header | object ref meta | object type meta | object value data |
The data are serialized using little endian byte order for all types.
Fory header format for xlang serialization:
| 1 byte bitmap | +--------------------------------+ | flags |
Detailed byte layout:
Byte 0: Bitmap flags
- Bit 0: xlang flag (0x01)
- Bit 1: oob flag (0x02)
- Bits 2-7: reserved
All data is encoded in little-endian format.
Reference tracking handles whether the object is null, and whether to track reference for the object by writing corresponding flags and maintaining internal state.
| Flag | Byte Value (int8) | Hex | Description |
|---|---|---|---|
| NULL FLAG | -3 | 0xFD | Object is null. No further bytes are written for this object. |
| REF FLAG | -2 | 0xFE | Object was already serialized. Followed by unsigned varint32 reference ID. |
| NOT_NULL VALUE FLAG | -1 | 0xFF | Object is non-null but reference tracking is disabled for this type. Object data follows immediately. |
| REF VALUE FLAG | 0 | 0x00 | Object is referencable and this is its first occurrence. Object data follows. Assigns next reference ID. |
Writing:
function write_ref_or_null(buffer, obj):
if obj is null:
buffer.write_int8(NULL_FLAG) // -3
return true // done, no more data to write
if reference_tracking_enabled:
ref_id = lookup_written_objects(obj)
if ref_id exists:
buffer.write_int8(REF_FLAG) // -2
buffer.write_varuint32(ref_id)
return true // done, reference written
else:
buffer.write_int8(REF_VALUE_FLAG) // 0
add_to_written_objects(obj, next_ref_id++)
return false // continue to serialize object data
else:
buffer.write_int8(NOT_NULL_VALUE_FLAG) // -1
return false // continue to serialize object data
Reading:
function read_ref_or_null(buffer):
flag = buffer.read_int8()
switch flag:
case NULL_FLAG (-3):
return (null, true) // null object, done
case REF_FLAG (-2):
ref_id = buffer.read_varuint32()
obj = get_from_read_objects(ref_id)
return (obj, true) // referenced object, done
case NOT_NULL_VALUE_FLAG (-1):
return (null, false) // non-null, continue reading
case REF_VALUE_FLAG (0):
reserve_ref_slot() // will be filled after reading
return (null, false) // non-null, continue reading
0REF_VALUE_FLAG is written (first occurrence)When reference tracking is disabled globally or for specific types, only the NULL and NOT_NULL VALUE flags will be used for reference meta. This reduces overhead for types that are known not to have references.
Languages with nullable and reference types by default (Java, Python, JavaScript):
In xlang mode, for cross-language compatibility:
Optional types (e.g., java.util.Optional, typing.Optional) are treated as nullableAnnotation examples:
// Java: use @Ref for reference tracking public class MyClass { @Nullable @Ref private Object refField; private String requiredField; }
# Python: use typing with fory field descriptors from pyfory import ForyField, Ref class MyClass: ref_field: ForyField(Ref[SomeType], nullable=True) required_field: ForyField(str, nullable=False)
Languages with non-nullable types by default:
| Language | Null Representation | Reference Tracking Support |
|---|---|---|
| Rust | Option::None | Via Rc<T>, Arc<T>, Weak<T> |
| C++ | std::nullopt, nullptr | Via std::shared_ptr<T>, weak_ptr<T> |
| Go | nil interface/pointer | Via pointer/interface types |
Important: For languages like Rust that don't have implicit reference semantics, reference tracking must use explicit smart pointers (Rc, Arc).
Every non-primitive value begins with a type ID that identifies its concrete type. The type ID is followed by optional type-specific metadata.
user_type_id as varuint32.user_type_id is a numeric ID (0~0xFFFFFFFE in current implementations).internal_type_id is one of ENUM, STRUCT, COMPATIBLE_STRUCT, EXT, or TYPED_UNION.NAMED_* internal type IDs and carry a namespace and type name (or shared TypeDef) instead.After the type ID:
user_type_id (registration by ID required on both sides).namespace and type_name as meta strings.Pure id-based enum, ext, and typed-union values therefore do not carry a TypeDef body. Receive-side TypeDef resource limits apply only when the stream actually carries shared TypeDef metadata.
ARRAY (42) is reserved for a future xlang extension for dedicated multi-dimensional arrays and is not used in current xlang streams.
Unregistered types are serialized as named types:
NAMED_ENUMNAMED_STRUCT (or NAMED_COMPATIBLE_STRUCT when meta share is enabled)NAMED_EXTNAMED_UNIONThe namespace is the package/module name and the type name is the simple class name.
When meta share is enabled, TypeDef metadata is written inline the first time a type is encountered, and subsequent occurrences only reference it.
Encoding:
marker = (index << 1) | flagflag = 0: new type definition followsflag = 1: reference to a previously written type definitionindex is the sequential index assigned to this type (starting from 0).Write algorithm:
(index << 1) | 1.index = next_id(index << 1)Read algorithm:
marker as varuint32.flag = marker & 1, index = marker >>> 1.flag == 1, use the cached TypeDef at index.flag == 0, read a TypeDef, cache it at index, and use it.TypeDef bytes include the 8-byte global header and optional size extension.
TypeDef describes a struct-like type (or a named enum/ext) for schema evolution and name resolution. It is encoded as:
| 8-byte global header | [optional size varuint] | TypeDef body |
The 8-byte header is a little-endian uint64:
varuint32(meta_size - 0xFF) follows immediately after the header.COMPRESS_META is reserved for a future xlang metadata-compression extension. Current xlang writers MUST leave this bit unset and current xlang readers MUST treat a set bit as unsupported.TypeDef body || header_low12_le. header_low12_le is two little-endian bytes containing the low 12 header bits (size, compress, and reserved bits); the upper four bits of the second byte are zero. Take lane 0 of the 128-bit MurmurHash3 result as a signed int64, left-shift it by 12 with two's-complement 64-bit wraparound, apply signed absolute value (leaving INT64_MIN unchanged), then mask with 0xfffffffffffff000. The final header is the masked hash bits OR-ed with the low 12 header bits.TypeDef body has a single layer (fields are flattened in class hierarchy order):
| meta header (1 byte) | type spec | field info ... |
Meta header byte for struct TypeDefs:
IS_STRUCT (1).COMPATIBLE.REGISTER_BY_NAME (1 = namespace + type name, 0 = numeric user type ID).num_fields (0-30).num_fields == 31, read an extra varuint32 and add it.Meta header byte for non-struct TypeDefs:
IS_STRUCT (0).Readers may reject a received TypeDef that exceeds runtime resource limits such as maximum metadata body bytes or maximum fields in one struct TypeDef. These limits are receive-side resource controls and do not change TypeDef wire encoding, type identity, dynamic loading, unknown-type handling, registration policy, or schema-evolution semantics.
Non-struct kind codes:
0: ENUM1: NAMED_ENUM2: EXT3: NAMED_EXT4: TYPED_UNION5: NAMED_UNION6-14: reserved15: extended-kind escape, rejected until definedType spec:
REGISTER_BY_NAME is set:namespace meta stringtype_name meta stringvaruint32Field info list:
Each field is encoded as:
| field header (1 byte) | field type info | [field name bytes] |
Field header layout:
UTF8, ALL_TO_LOWER_SPECIAL, LOWER_UPPER_DIGIT_SPECIAL, or TAG_ID)size = (name_bytes_length - 1)size = tag_idsize == 0b1111, read varuint32(size - 15) and add itField type info:
varuint32(type_id) (small7) without flags.LIST / SET, an element type follows, encoded as (nested_type_id << 2) | (nullable << 1) | tracking_ref.MAP, key type and value type follow, both encoded the same way.*_ARRAY type IDs; other arrays are encoded as LIST.Field names:
TAG_ID encoding is used, no name bytes are written.snake_case before encoding for cross-language compatibility.Field order:
TypeDef field lists use the same ordering defined in Field order. Compatible decoders must still match fields by name or tag ID rather than relying only on position.
Meta string is a compressed encoding for metadata strings such as field names, type names, and namespaces. This compression significantly reduces the size of type metadata in serialized data.
| ID | Name | Bits/Char | Character Set |
|---|---|---|---|
| 0 | UTF8 | 8 | Any UTF-8 character |
| 1 | LOWER_SPECIAL | 5 | a-z . _ $ | |
| 2 | LOWER_UPPER_DIGIT_SPECIAL | 6 | a-z A-Z 0-9 . _ |
| 3 | FIRST_TO_LOWER_SPECIAL | 5 | First char uppercase, rest a-z . _ |
| 4 | ALL_TO_LOWER_SPECIAL | 5 | a-z A-Z . _ (uppercase escaped) |
| Character | Code (binary) | Code (decimal) |
|---|---|---|
| a-z | 00000-11001 | 0-25 |
| . | 11010 | 26 |
| _ | 11011 | 27 |
| $ | 11100 | 28 |
| | | 11101 | 29 |
Note: The | character is used as an escape sequence in ALL_TO_LOWER_SPECIAL encoding.
| Character | Code (binary) | Code (decimal) |
|---|---|---|
| a-z | 000000-011001 | 0-25 |
| A-Z | 011010-110011 | 26-51 |
| 0-9 | 110100-111101 | 52-61 |
| . | 111110 | 62 |
| _ | 111111 | 63 |
For strings containing only a-z, ., _, $, |:
function encode_lower_special(str):
bits = []
for char in str:
bits.append(lookup_lower_special[char]) // 5 bits each
// Pad to byte boundary
total_bits = len(str) * 5
padding_bits = (8 - (total_bits % 8)) % 8
// First bit indicates if last char should be stripped (due to padding)
strip_last = (padding_bits >= 5)
if strip_last:
prepend bit 1
else:
prepend bit 0
return pack_bits_to_bytes(bits)
For strings like MyFieldName where only the first character is uppercase:
function encode_first_to_lower_special(str):
// Convert first char to lowercase
modified = str[0].lower() + str[1:]
// Then use LOWER_SPECIAL encoding
return encode_lower_special(modified)
For strings with multiple uppercase characters like MyTypeName:
function encode_all_to_lower_special(str):
result = ""
for char in str:
if char.is_upper():
result += "|" + char.lower() // Escape uppercase with |
else:
result += char
return encode_lower_special(result)
Example: MyType → |my|type → encoded with LOWER_SPECIAL
function choose_encoding(str):
if all chars in str are in [a-z . _ $ |]:
return LOWER_SPECIAL
if first char is uppercase AND rest are in [a-z . _]:
return FIRST_TO_LOWER_SPECIAL
if all chars are in [a-z A-Z . _]:
lower_special_size = encode_all_to_lower_special(str).size
luds_size = encode_lower_upper_digit_special(str).size
if lower_special_size <= luds_size:
return ALL_TO_LOWER_SPECIAL
else:
return LOWER_UPPER_DIGIT_SPECIAL
if all chars are in [a-z A-Z 0-9 . _]:
return LOWER_UPPER_DIGIT_SPECIAL
return UTF8
Meta strings are written with a header that includes the encoding type:
| 3 bits encoding | 5+ bits length | encoded bytes |
Or for larger strings:
| varuint: (length << 3) | encoding | encoded bytes |
Different contexts use different special characters:
| Context | Special Chars | Notes |
|---|---|---|
| Field Name | . _ $ | | $ for inner classes, | for escape |
| Namespace | . _ | Package/module separators |
| Type Name | $ _ | $ for inner classes in Java |
Meta strings are deduplicated within a serialization session:
First occurrence: | (length << 1) | [hash if large] | encoding | bytes | Reference: | ((id + 1) << 1) | 1 |
false, 1 for trueb & 0x80 == 0x80), then the next byte should be read until a byte with unset continuation bit.Encoding Algorithm:
function write_varuint32(value):
while value >= 0x80:
buffer.write_byte((value & 0x7F) | 0x80) // 7 bits of data + continuation bit
value = value >> 7
buffer.write_byte(value) // final byte without continuation bit
Decoding Algorithm:
function read_varuint32():
result = 0
shift = 0
while true:
byte = buffer.read_byte()
result = result | ((byte & 0x7F) << shift)
if (byte & 0x80) == 0:
break
shift = shift + 7
return result
Byte sizes by value range:
| Value Range | Bytes |
|---|---|
| 0 ~ 127 | 1 |
| 128 ~ 16383 | 2 |
| 16384 ~ 2097151 | 3 |
| 2097152 ~ 268435455 | 4 |
| 268435456 ~ 4294967295 | 5 |
ZigZag Encoding:
// Encode: convert signed to unsigned zigzag_value = (value << 1) ^ (value >> 31) // Decode: convert unsigned back to signed original = (zigzag_value >> 1) ^ (-(zigzag_value & 1)) // Or equivalently: original = (zigzag_value >> 1) ^ (~(zigzag_value & 1) + 1)
ZigZag encoding maps signed integers to unsigned integers so that small absolute values (positive or negative) have small encoded values:
| Original | ZigZag Encoded |
|---|---|
| 0 | 0 |
| -1 | 1 |
| 1 | 2 |
| -2 | 3 |
| 2 | 4 |
| ... | ... |
Uses PVL (Progressive Variable-Length) encoding:
function write_varuint64(value):
while value >= 0x80:
buffer.write_byte((value & 0x7F) | 0x80)
value = value >> 7
buffer.write_byte(value)
| Value Range | Bytes |
|---|---|
| 0 ~ 127 | 1 |
| 128 ~ 16383 | 2 |
| ... | ... |
| 2^56 ~ 2^63-1 | 9 |
Optimized for unsigned values that fit in 31 bits (common case for IDs, sizes, counts, etc.):
if value in [0, 2147483647]: // fits in 31 bits (2^31 - 1), full unsigned range
write 4 bytes: ((int32) value) << 1 // bit 0 is 0, indicating 4-byte encoding
else:
write 1 byte: 0x01 // bit 0 is 1, indicating 9-byte encoding
write 8 bytes: value as little-endian uint64
Reading:
first_int32 = read_int32_le()
if (first_int32 & 1) == 0:
return (uint64)(first_int32 >> 1) // 4-byte encoding, unsigned
else:
return read_uint64_le() // read remaining 8 bytes
Note: TAGGED_UINT64 uses the full 31 bits for positive values [0, 2^31-1], compared to TAGGED_INT64 which splits the range for signed values [-2^30, 2^30-1].
A specialized encoding used for string headers that combines size (up to 36 bits) with encoding flags:
// Write: encodes (size << 2) | encoding_flags
function write_varuint36_small(value):
if value < 0x80:
buffer.write_byte(value)
else:
// Standard varint encoding for values >= 128
write_varuint64(value)
This encoding is optimized for the common case where string length fits in 7 bits (strings < 32 characters).
Uses ZigZag encoding first, then PVL varint:
// Encode zigzag_value = (value << 1) ^ (value >> 63) write_varuint64(zigzag_value) // Decode zigzag_value = read_varuint64() value = (zigzag_value >> 1) ^ (-(zigzag_value & 1))
Optimized for small signed values:
if value in [-1073741824, 1073741823]: // fits in 30 bits + sign ([-2^30, 2^30-1])
write 4 bytes: ((int32) value) << 1 // bit 0 is 0, indicating 4-byte encoding
else:
write 1 byte: 0x01 // bit 0 is 1, indicating 9-byte encoding
write 8 bytes: value as little-endian int64
Reading:
first_int32 = read_int32_le()
if (first_int32 & 1) == 0:
return (int64)(first_int32 >> 1) // 4-byte encoding, sign-extended
else:
return read_int64_le() // read remaining 8 bytes
Note: TAGGED_INT64 uses 30 bits + sign for values [-2^30, 2^30-1], while TAGGED_UINT64 uses full 31 bits for unsigned values [0, 2^31-1].
Format:
| varuint36_small: (size << 2) | encoding | binary data |
The header is encoded using varuint36_small format, which combines the byte length and encoding type:
header = (byte_length << 2) | encoding_type
| Encoding Type | Value | Description |
|---|---|---|
| LATIN1 | 0 | ISO-8859-1 single-byte encoding |
| UTF16 | 1 | UTF-16 encoding (2 bytes per code unit) |
| UTF8 | 2 | UTF-8 variable-length encoding |
| Reserved | 3 | Reserved for future use |
Writing:
function write_string(str):
bytes = encode_to_bytes(str, chosen_encoding)
header = (bytes.length << 2) | encoding_type
buffer.write_varuint36_small(header)
buffer.write_bytes(bytes)
Reading:
function read_string():
header = buffer.read_varuint36_small()
encoding = header & 0x03
byte_length = header >> 2
bytes = buffer.read_bytes(byte_length)
return decode_bytes(bytes, encoding)
Writing:
| Language | Encoding Strategy |
|---|---|
| Java (JDK8) | Detect at runtime: LATIN1 if all chars < 256, else UTF16 |
| Java (JDK9+) | Use String's internal coder: LATIN1 or UTF16 |
| Python | Can write LATIN1, UTF16, or UTF8 based on string content |
| C++ | UTF8 (std::string) or UTF16 (std::u16string) |
| Rust | UTF8 (String) |
| Go | UTF8 (string) |
| JavaScript | UTF8 |
Reading: All languages support decoding all three encodings (LATIN1, UTF16, UTF8).
Recommendation: Select encoding based on maximum performance - use the encoding that matches the language's native string representation to avoid conversion overhead.
Empty strings are encoded with header 0 (length 0, any encoding) followed by no data bytes.
Duration is an absolute length of time, independent of any calendar/timezone, as a count of seconds and nanoseconds.
Format:
| signed varint64: seconds | signed int32: nanoseconds |
seconds: Number of seconds in the duration, encoded as a signed varint64. Can be positive or negative.nanoseconds: Nanosecond adjustment to the duration, encoded as a signed int32.Notes:
nanoseconds is always in [0, 1_000_000_000).seconds = 0 and nanoseconds = 0.-0.5s is encoded as seconds = -1, nanoseconds = 500_000_000.duration = seconds + nanoseconds / 1_000_000_0000 <= nanoseconds < 1_000_000_000After decoding seconds and nanoseconds, the duration value is reconstructed as the exact duration represented by:
seconds + nanoseconds / 1_000_000_000
Format:
| varuint32: length | 1 byte elements header | [optional type info] | elements data |
The elements header is a single byte that encodes metadata about the collection elements to optimize serialization:
| bit 7-4 (reserved) | bit 3 | bit 2 | bit 1 | bit 0 | +--------------------+-------------+------------------+----------+-----------+ | reserved | is_same_type| is_decl_elem_type| has_null | track_ref |
| Bit | Name | Value | Meaning when SET (1) | Meaning when UNSET (0) |
|---|---|---|---|---|
| 0 | track_ref | 0x01 | Track references for elements | Don't track element references |
| 1 | has_null | 0x02 | Payload contains null element markers | No null elements (skip null checks) |
| 2 | is_decl_elem_type | 0x04 | Elements are the declared generic type | Element types differ from declared type |
| 3 | is_same_type | 0x08 | All elements have the same concrete type | Elements have different concrete types |
Common header values:
| Header | Hex | Meaning |
|---|---|---|
| 0x0C | 12 | Declared type + same type, non-null, no ref tracking (optimal) |
| 0x0D | 13 | Declared type + same type, non-null, with ref tracking |
| 0x0E | 14 | Declared type + same type, may have nulls, no ref tracking |
| 0x08 | 8 | Same type but not declared type (type info written once) |
| 0x00 | 0 | Different types, non-null, no ref tracking (type per element) |
When is_decl_elem_type (bit 2) is NOT set, the element type info is written once after the header if is_same_type (bit 3) is set:
| header (0x08) | type_id (varuint32) | elements... |
When both is_decl_elem_type and is_same_type are NOT set, type info is written per element.
The header determines how each element is serialized:
Based on the elements header, the serialization of elements data may skip ref flag/null flag/element type info.
fory = ... buffer = ... elems = ... if element_type_is_same: if not is_declared_type: fory.write_type(buffer, elem_type) elem_serializer = get_serializer(...) if track_ref: for elem in elems: if not ref_resolver.write_ref_or_null(buffer, elem): elem_serializer.write(buffer, elem) elif has_null: for elem in elems: if elem is None: buffer.write_byte(null_flag) else: buffer.write_byte(not_null_flag) elem_serializer.write(buffer, elem) else: for elem in elems: elem_serializer.write(buffer, elem) else: if track_ref: for elem in elems: fory.write_ref(buffer, elem) elif has_null: for elem in elems: fory.write_nullable(buffer, elem) else: for elem in elems: fory.write(buffer, elem)
CollectionSerializer#writeElements can be taken as an example.
Primitive array are taken as a binary buffer, serialization will just write the length of array size as an unsigned int, then copy the whole buffer into the stream. Multi-byte element arrays are always encoded in little-endian element order; implementations whose native typed-array storage uses another byte order must swap or write elements explicitly instead of copying native storage bytes unchanged.
Such serialization won't compress the array. If users want to compress primitive array, users need to register custom serializers for such types or mark it as list type.
Float array specifics:
varuint length, then raw bytes in little endian order.varuint length, then raw bytes in little endian order.Current xlang does not define a dedicated multi-dimensional array/tensor encoding. Multi-dimensional arrays are serialized as nested lists, while one-dimensional primitive arrays use the *_ARRAY type IDs. Internal type ID ARRAY (42) is reserved for a future dedicated multi-dimensional array encoding and is not used in current xlang streams.
Object array is serialized using the list format. Object component type will be taken as list element generic type.
Map uses a chunk-based format to handle heterogeneous key-value pairs efficiently:
| varuint32: total_size | chunk_1 | chunk_2 | ... | chunk_n |
Each chunk contains up to 255 key-value pairs with the same metadata characteristics:
| 1 byte | 1 byte | variable bytes | +--------------+----------------+------------------------------+ | KV header | chunk size N | N key-value pairs (N*2 obj) |
The KV header is a single byte encoding metadata for both keys and values:
| bit 7-6 | bit 5 | bit 4 | bit 3 | bit 2 | bit 1 | bit 0 | +------------+---------------+--------------+---------------+---------------+--------------+---------------+ | reserved | val_decl_type | val_has_null | val_track_ref | key_decl_type | key_has_null | key_track_ref |
| Bit | Name | Value | Meaning when SET (1) |
|---|---|---|---|
| 0 | key_track_ref | 0x01 | Track references for keys |
| 1 | key_has_null | 0x02 | Keys may be null (rare, usually invalid) |
| 2 | key_decl_type | 0x04 | Key is the declared generic type |
| 3 | val_track_ref | 0x08 | Track references for values |
| 4 | val_has_null | 0x10 | Values may be null |
| 5 | val_decl_type | 0x20 | Value is the declared generic type |
Common KV header values:
| Header | Hex | Meaning |
|---|---|---|
| 0x24 | 36 | Key + value are declared types, non-null, no ref tracking (optimal) |
| 0x2C | 44 | Key + value declared types, value tracks refs |
| 0x34 | 52 | Key + value declared types, value may be null |
| 0x00 | 0 | Key + value not declared types, non-null, no ref tracking |
Map iteration is expensive. Computing a single header for all pairs would require two passes. The chunk-based approach allows:
When fory will use first key-value pair to predict header optimistically, it can‘t know how many pairs have same meta(tracking kef ref, key has null and so on). If we don’t write chunk by chunk with max chunk size, we must write at least X bytes to take up a place for later to update the number which has same elements, X is the num_bytes for encoding varint encoding of map size.
And most map size are smaller than 255, if all pairs have same data, the chunk will be 1. This is common in golang/rust, which object are not reference by default.
Also, if only one or two keys have different meta, we can make it into a different chunk, so that most pairs can share meta.
The implementation can accumulate read count with map size to decide whether to read more chunks.
Enums are serialized as an unsigned varint enum ID.
This means the wire contract is always an enum ID. When the enum ID comes from declaration order, reordering enum values changes the wire IDs and can change the deserialized result. For cross-language or long-lived schemas, users should prefer explicit stable enum IDs.
Timestamp represents a point in time independent of any calendar/timezone. It is encoded as:
seconds (int64): seconds since Unix epoch (1970-01-01T00:00:00Z)nanos (uint32): nanosecond adjustment within the secondOn write, implementations must normalize negative timestamps so that nanos is always in [0, 1_000_000_000). This is a fixed-size 12-byte payload (8 bytes seconds + 4 bytes nanos).
Date represents a date without timezone. It is encoded as:
days (varint64): signed count of days since the Unix epoch (1970-01-01)The value is reconstructed as LocalDate.ofEpochDay(days) or the equivalent calendar-date constructor in the target language implementation.
This varint64 encoding applies to xlang serialization only. Native, language-specific local-date encodings are unchanged.
A decimal value is encoded as:
scale: signed varint32unscaledHeader: unsigned varint64payload: present only for large unscaled valuesThe mathematical value is:
value = unscaled × 10^-scale
scale is encoded as signed varint32.scale carries no extra flags or mode bits.-10_000 <= scale <= 10_000.unscaledHeader selects the encoding of unscaled:
(unscaledHeader & 1) == 0, the value uses the small encoding.(unscaledHeader & 1) == 1, the value uses the big encoding.For small values, unscaled must fit in signed 64-bit range and the zigzag-encoded value must fit in 63 bits.
Encoding:
unscaledHeader = zigzag(unscaled) << 1Decoding:
unscaled = zigzagDecode(unscaledHeader >>> 1)For big values, unscaled is encoded as sign plus magnitude bytes.
Encoding:
sign = 0 if unscaled >= 0, otherwise 1magnitude = abs(unscaled)len = byte length of magnitude in canonical minimal little-endian formmeta = (len << 1) | signunscaledHeader = (meta << 1) | 1payload = magnitude as canonical minimal little-endian bytesFor arbitrary-precision decimal carriers, len must not exceed 10_000. This limit counts only the canonical unsigned binary bytes of abs(unscaled); it does not count the header, decimal digits, or textual representations.
Decoding:
meta = unscaledHeader >>> 1sign = meta & 1len = meta >>> 1len bytes as little-endian unsigned magnitudeunscaled = magnitude if sign == 0, otherwise -magnitudepayload must be the minimal little-endian representation.len > 0 and payload[len - 1] != 0.After decoding scale and unscaled, the decimal value is reconstructed as:
value = unscaled × 10^-scale
The scale and magnitude bounds are accepted-value limits, not changes to the wire encoding. Writers must reject values outside them, and readers must reject them before allocating the magnitude or constructing the decimal while still checking that an accepted body is readable and canonically encoded. A target with a fixed-range decimal carrier may impose a stricter native range.
The compatible scalar conversion limits described earlier in this specification remain independent. In particular, conversion that formats plain text, rescales, quantizes, or otherwise expands output must retain its own expected output-length checks; the ordinary decimal scale bound does not replace them.
Struct means object of class/pojo/struct/bean/record type. Struct values are serialized by writing fields in Fory order. The type meta before the value is written according to the rules in Type Meta.
Field order must be deterministic and identical across languages. This section defines the language-neutral ordering algorithm; implementations must follow the rules here rather than any language-specific helper classes.
For every field, compute a stable identifier used for ordering:
@ForyField(id=...)), use the tag ID.snake_case.Configured tag IDs must be non-negative. A negative configured tag ID is invalid; languages may use a negative value only as a default or internal sentinel for “no tag ID configured”, which falls back to the snake_case field name and is not a tag ID. Tag IDs must be unique within a type; duplicate tag IDs are invalid.
Field identifiers compare as follows:
snake_case names lexicographically.Assign each field to exactly one group in the following order:
Within each group, apply the following sort keys in order until a difference is found:
Primitive groups (1 and 2):
VARINT32, VAR_UINT32, VARINT64, VAR_UINT64, TAGGED_INT64, TAGGED_UINT64).Non-primitive group (3):
If two fields still compare equal after the rules above, preserve a deterministic order by comparing declaring class name and then the original field name. This tie-breaker should be reachable only in invalid schemas (e.g., duplicate tag IDs).
Object value layout:
| [optional 4-byte schema hash] | field values |
The schema hash is written only when class-version checking is enabled. It is the low 32 bits of a MurmurHash3 x64_128 of the struct fingerprint string:
<field_id_or_name>,<field_type_fingerprint>;.field_type_fingerprint is recursive:<type_id>,<ref>,<nullable>LIST / SET: <type_id>,<ref>,<nullable>[<element_fingerprint>]MAP: <type_id>,<ref>,<nullable>[<key_fingerprint>|<value_fingerprint>]nullable and ref policy are always hashed as 0. Only the root field nullable and ref bits participate in schema hash, because nested reads honor the wire null/ref flags directly.Field values are serialized in Fory order. Primitive fields are written as raw values (nullable primitives include a null flag). Non-primitive fields write ref/null flags as needed and then the value; polymorphic fields include type meta.
The field value layout is the same as same-schema mode, but the type meta for COMPATIBLE_STRUCT and NAMED_COMPATIBLE_STRUCT uses shared TypeDef entries. Deserializers use TypeDef to map fields by name or tag ID and to honor nullable/ref flags from metadata; unknown fields are skipped.
Union values are encoded using three union type IDs so the union schema identity lives in type meta (like STRUCT/ENUM/EXT) and is easy to carry inside Any.
union Contact [id=0] { string email = 0; int32 phone = 1; }
Rules:
none. Multiple logical fields require an explicitly declared struct value; a binding MUST NOT synthesize that struct from a host enum variant.= 0, = 1, ...).| Type ID | Name | Meaning |
|---|---|---|
| 33 | UNION | Union value, schema identity not embedded |
| 34 | TYPED_UNION | Union value with registered numeric type ID |
| 35 | NAMED_UNION | Union value with embedded type name / shared TypeDef |
Type meta encoding:
UNION (33): no additional type meta payload.TYPED_UNION (34): write user_type_id as varuint32 after the type ID.NAMED_UNION (35): followed by named type meta (namespace + type name, or shared TypeDef marker/body).Field TypeDef metadata MUST use UNION for statically typed union fields, including generated typed ADT union fields. It MUST NOT use TYPED_UNION or NAMED_UNION there, because the field owner already supplies the union schema.
A union payload is:
| case_id (varuint32) | case_value (Any-style value) |
case_id is the union alternative tag number. Runtime APIs MAY expose zero-based ordinal indexes for generic union carriers; those ordinals are valid wire case_id values when they are the schema's alternative IDs.
case_value MUST be encoded as a full xlang value:
| field_ref_meta | field_value_type_meta | field_value_bytes |
This is required even for primitives so unknown alternatives can be skipped safely.
If a reader sees a case_id that is not present in its local union schema, it SHOULD preserve the unknown case when the target language has a language-neutral carrier for it. Such a carrier MUST expose the original case ID and decoded value, and it MUST retain only implementation-internal wire type ID state needed for reserialization. It MUST NOT store resolver-owned type metadata or other context-owned state. Writers MUST use the stored original case ID for the union envelope, not any generated carrier marker. Unknown-case payload writers MUST emit the Any-style payload body in wire order: ref metadata first, then full value type metadata, then value bytes. For internal numeric type IDs, the type ID byte is the complete value type metadata and the payload writer MAY use the stored wire type ID to preserve fixed, variable, or tagged integer encodings when the decoded value has the expected concrete value type. These scalar numeric payloads are not reference-tracked, so their ref metadata is NotNullValue. Otherwise it MUST fall back to the language implementation‘s ordinary polymorphic Any-value writer. Unknown carriers are implementation-provided forward-compatibility containers, not entries in the local schema case table; schema-defined union cases MAY use 0..N. When an unknown carrier is written back, the union envelope MUST use the carrier’s original peer schema case ID unchanged, including 0 if that was the original peer schema case ID.
UNION (schema known from context)
| ... outer ref meta ... | type_id=UNION(33) | case_id | case_value |
TYPED_UNION (schema identified by numeric id)
| ... outer ref meta ... | type_id=TYPED_UNION(34) | user_type_id | case_id | case_value |
user_type_id: varuint32 numeric registration ID for the union schema.
NAMED_UNION (schema embedded by name/typedef)
| ... outer ref meta ... | type_id=NAMED_UNION(35) | name_or_typedef | case_id | case_value |
type_id.TYPED_UNION, read user_type_id and resolve the union schema by ID.NAMED_UNION, read named type meta and resolve the union schema.case_id.case_value as Any-style value (ref meta + type meta + value).If case_id is unknown, the decoder MUST still consume the case value using field_value_type_meta and standard skipValue(type_id).
UNION when the union schema is known from context. This includes statically typed generated union fields: the owning field metadata supplies the union schema, so the field type ID remains UNION even if the root or dynamic value form would identify the union as TYPED_UNION or NAMED_UNION.TYPED_UNION for dynamic containers when numeric registration is available.NAMED_UNION when name-based resolution is preferred or required.case_id is a stable identifier; added alternatives are forward compatible and unknown cases can be skipped.Type will be serialized using type meta format.
See Xlang Implementation Guide documentation.