google.protobuf.Struct, Value, and ListValue are designed to
carry arbitrary JSON-shaped data through proto. They're the WKT slice
with the most foot-guns — null sentinel, disambiguation between Struct
vs Value vs ListValue, integer-vs-float — so this how-to walks through
the Lua-side conventions.
| Proto type | Lua-side value | Use when |
|---|---|---|
Struct |
plain Lua table with string keys | The field is typed Struct. |
ListValue |
plain Lua array (1-based contiguous) | The field is typed ListValue. |
Value |
native Lua of matching shape (scalar / table / array / pb.NULL) |
The field is Value — the codec auto-detects via Lua type. |
Pick Struct or ListValue whenever you can — they're unambiguous.
Pick Value when the schema needs a "this could be anything" cell.
Value fieldlocal pb = require('pb')
local hello = require('full.hello.hello_pb') -- has Event.attribute: Value
-- Scalars round-trip natively:
hello.Event_encode({attribute = 'a string'})
hello.Event_encode({attribute = 42})
hello.Event_encode({attribute = true})
hello.Event_encode({attribute = pb.NULL}) -- JSON null
For container shapes, the codec needs to know whether you mean a
Struct (string-keyed map) or a ListValue (1-based array). Two
ways to disambiguate:
-- 1. Tag with pb.wkt.struct(t) / pb.wkt.list(t):
hello.Event_encode({attribute = pb.wkt.struct({nested = 'x'})})
hello.Event_encode({attribute = pb.wkt.list({1, 2, 3})})
-- 2. Or pass a table the codec can auto-classify: dictionary-shaped
-- tables become Struct, 1-based contiguous arrays become ListValue.
hello.Event_encode({attribute = {nested = 'x'}}) -- Struct
hello.Event_encode({attribute = {1, 2, 3}}) -- ListValue
The auto-classifier is good enough for most cases. Use the tags when
the table is ambiguous (empty {}, mixed keys, etc.) — explicit beats
the heuristic.
Struct field (typed)When the schema is google.protobuf.Struct directly, the field always
holds a string-keyed table — no disambiguation needed:
hello.Event_encode({
payload = {
greeting = 'hi',
count = 42,
active = true,
when = pb.NULL,
nested = {a = 1, b = 2},
},
})
Decoded:
local d = hello.Event_decode(bytes)
print(d.payload.greeting) -- 'hi'
print(d.payload.count) -- 42
print(d.payload.when == pb.NULL) -- true
print(d.payload.nested.a) -- 1
Two distinct concepts:
t.foo is nil (for
Value / Struct) or {} (proto3 default for repeated/map).Value{null_value} /
Struct value of null. Lua-side sentinel is pb.NULL.hello.Event_encode({payload = {seen = pb.NULL}}) -- explicit null
hello.Event_encode({payload = {}}) -- empty Struct
hello.Event_encode({}) -- payload absent
pb.NULL is equal to box.NULL. Use pb.NULL so app code doesn't
need to require('box') just for the sentinel.
Proto3 Value is a double under the hood — integers and floats
share the wire encoding. The codec preserves Lua integer-vs-float when
encoding (42 stays integer; 42.5 stays float), but on the
decode side everything that travels through Value comes back as a
Lua number. If you need a 64-bit integer through Value you must
either:
Struct with two fields (hi / lo), orint64).This is a Value limitation, not a codec one — JSON has the same
trade-off and Value is Struct-shaped for JSON-compat reasons.
ListValue carries an array of Values, so mixed-type arrays work:
hello.Event_encode({tags = {'a', 'b', 3, true, pb.NULL}})
local d = hello.Event_decode(bytes)
-- d.tags = {'a', 'b', 3, true, pb.NULL}
Each entry passes through the same Value auto-classification as
above, so tables inside a ListValue need the same pb.wkt.struct /
pb.wkt.list tagging when ambiguous.
The proto3 JSON mapping for Struct / Value / ListValue is
"native JSON" — no envelope, just the shape itself. pb.json.encode
emits the table content directly:
local pbjson = require('pb').json
print(pbjson.encode(hello.Event_descriptor, {
payload = {greeting = 'hi'},
}))
-- {"payload":{"greeting":"hi"}}
This matches mainline protoc and is what makes Struct useful as a
JSON-in-proto cell.
Struct which is JSON-typed).