Skip to main content
The three composites take other Types as arguments and nest freely, so a schema can describe structured data without falling back to the dynamic encoder.

Optional

Type<T?>
Writes a one-byte presence flag, then the inner value only if it is not nil.
Type<T>
required
The inner type, used only when the value is present.
This is the only way to send nil under a schema. The plain constructors pass straight through to buffer.write*, which errors on nil with an unhelpful message:
Anything that can be absent, such as an optional target, an unset preference, or a value still loading, has to be wrapped.

Size

A nil costs one byte, which is the same as the dynamic encoder’s TYPE_NIL tag. The flag is only overhead when the value is usually present, so do not wrap fields that are never nil.

Array

Type<{T}>
A varint element count followed by each element encoded with t.
Type<T>
required
The element type. Every element has to be encodable by it, because arrays are homogeneous.
number
Optional maximum element count, asserted on write.
Read preallocates with table.create(n), so decoding a large array does not repeatedly resize.
Arrays are homogeneous and dense. T.Array(T.UInt8()) over { 1, "two", 3 } errors on the second element, and a sparse table like { [1] = "a", [3] = "c" } has #v == 1, so only the first element is written and the rest is silently lost. Use T.Array(T.Any()) for mixed contents, or a T.Struct() if the shape is actually fixed.
As with T.String, maxLen is checked on write only, because Read ignores it. It raises array exceeds max length at your call site rather than dropping the message, and it provides no protection against an oversized array from a tampered client. Validate inbound lengths in your server listener.

Struct

Type<table>
Encodes named fields in declared order. No keys are transmitted, because the layout carries them.
{ { string | Type } }
required
An array of { name, type } pairs. The order is part of the wire format.
This is the most byte-efficient way to send a table. Compare the payload above:String keys dominate dynamically encoded dictionaries, and a struct removes them entirely.
The declared field order has to match on both sides, since it determines the byte sequence. Reordering the pairs in one place and not the other silently misreads the packet: reads land on the wrong offsets and every subsequent value in the packet is corrupted.Add new fields at the end, and treat the field list as a versioned wire contract.
Only declared fields are transmitted. Extra keys on the table are ignored, and a missing key is read as nil and passed to the field’s Write, which errors for any non-optional type. Wrap fields that may be absent in T.Optional.
The type annotation is {{ string } | Type}, which is loose, so Luau will not check that your pairs are well formed. A typo like { T.UInt16(), "Health" }, with the order reversed, passes analysis and fails at runtime with a nil index error. Write the name first.

Nesting

Composites take any Type, including each other.
Extract nested types into named locals when they repeat. Each constructor call allocates a fresh table, so reusing one is both clearer and cheaper:
A Type is immutable in practice, being just two closures, so sharing one across schemas is safe.

Choosing a composite

Composites are structural, not recursive. There is no way to declare a type that contains itself, so a tree or linked structure of arbitrary depth cannot be described by a schema. Use T.Any() for those, and remember that circular references overflow the stack.

Next steps

Schemas guide

When schemas are worth declaring at all.

Custom types

Writing your own Write and Read pair.