Props and encoding
alacris coerces an attribute using the type of the prop’s default in
define(). Everything here follows from that.
define('x-demo', { props: { count: 0, tags: [] } });// ^ number ^ objectcount will be coerced with +v; tags with JSON.parse. So the Go type you
pass has to line up with the type of the default. Generated wrappers guarantee
that; hand-written Prop calls are on your own.
The table
Section titled “The table”| Go type | Attribute | Matching default in define() |
|---|---|---|
string, fmt.Stringer | the text | a string |
bool | "true" / "false" | a boolean |
int…int64, uint…uint64 | decimal | a number |
float32, float64 | shortest round-tripping form | a number |
time.Time | RFC 3339 | a string |
time.Duration | whole milliseconds | a number |
slices, maps, structs, json.Marshaler | JSON | an object or array |
nil, nil pointer, nil slice/map | omitted | anything |
return alacris.E("x-demo"). Prop("label", "hello"). Prop("count", 42). Prop("ratio", 1.5). Prop("open", true). Prop("shut", false)<x-demo label="hello" count="42" ratio="1.5" open="true" shut="false"></x-demo>Booleans are always written out, never signalled by presence.
Objects and arrays cross too
Section titled “Objects and arrays cross too”Because object defaults are parsed with JSON.parse, a struct or a map
crosses as an attribute. No post-load property assignment, no hydration
payload, and a page that is complete before any JavaScript has run.
type point struct { X int `json:"x"` Y int `json:"y"`}return alacris.E("x-demo"). Prop("tags", []string{"a", "b"}). Prop("origin", point{X: 1, Y: 2}). Prop("lookup", map[string]int{"b": 2, "a": 1}). Prop("at", time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)). Prop("timeout", 1500*time.Millisecond)<x-demo tags="["a","b"]" origin="{"x":1,"y":2}" lookup="{"a":1,"b":2}" at="2026-08-08T12:00:00Z" timeout="1500"></x-demo>Objects and arrays cross as JSON, so no post-load assignment is needed.
The live area is showing the component reporting the JavaScript type each
prop arrived as. tags is an array. origin is an object. They were attribute
strings a moment ago.
Booleans
Section titled “Booleans”A boolean prop is always written out explicitly, never signalled by presence.
The reason is in coerce:
if (v === null) return typeof d === 'boolean' ? false : d;v is null when an attribute is removed. So removing a boolean attribute
returns false, even when the prop’s declared default is true. Presence and
absence cannot express “leave it alone”, so the library never relies on them.
Numbers
Section titled “Numbers”A numeric prop is coerced with +v, which produces a JavaScript number (a
float64). Anything past 253−1 cannot survive that.
alacris.E("x-y").Prop("id", int64(9007199254740993))// render fails: alacris: integer exceeds JavaScript's safe integer range;// encode it as a stringIt is an error rather than a rounding because a silently altered identifier is the worst outcome available. Send large ids as strings and have the component treat them as strings.
NaN and the infinities are refused for the same reason: they have no
attribute form that survives the round trip.
Nothing needs escaping
Section titled “Nothing needs escaping”Interpolated values are attribute values, never markup. There is nothing to escape by hand and no way to forget:
// Interpolated values are attribute values, never markup. There is// nothing to escape by hand and no way to forget.return alacris.E("x-demo"). Prop("label", `" onload="alert(1)`). Prop("payload", map[string]string{"html": "</x-demo><script>"})<x-demo label="" onload="alert(1)" payload="{"html":"\u003c/x-demo\u003e\u003cscript\u003e"}"></x-demo>Values are attribute values, never markup.
Note the JSON prop: quotes inside it are escaped so the attribute cannot end
early, and the <script> in the payload is just text when it arrives.
Omitting versus sending a zero
Section titled “Omitting versus sending a zero”Generated wrappers leave a prop off when the value is the Go zero value or equal to the component’s own default. Both mean “I did not set this”.
components.UserCard(components.UserCardProps{Name: ""}) // no name attribute; component uses 'anon'components.UserCard(components.UserCardProps{Name: "anon"}) // sameTo send a zero value on purpose, set it on the returned element:
components.UserCard(components.UserCardProps{}).Prop("name", "") // name=""If that trade is wrong for your project, generate with pointer fields instead:
alacris-go generate ./web -o ./internal/components -optional=pointercomponents.UserCard(components.UserCardProps{Name: ptr("")}) // unambiguous, more typingAttribute names
Section titled “Attribute names”The conversion from prop name to attribute name is define.js’s, reproduced
exactly:
const kebab = s => s.replace(/[A-Z]/g, c => '-' + c.toLowerCase());| Prop | Attribute |
|---|---|
name | name |
maxCount | max-count |
URL | -u-r-l |
Name | -name |
The last two are quirks, not bugs. They are reproduced because the element is
listening for exactly what define() computed, and being tasteful instead
would produce an attribute nothing reads.
Custom types
Section titled “Custom types”A type implementing json.Marshaler, fmt.Stringer or
encoding.TextMarshaler encodes through it, in that order of preference. So a
domain type can control its own representation:
type Money struct{ Cents int64 }
func (m Money) MarshalJSON() ([]byte, error) { return json.Marshal(map[string]any{"cents": m.Cents, "display": m.String()})}Tell the generator about it with a JSDoc tag, and the field is typed:
/** * @prop {go:money.Money} price * @goimport money example.com/shop/money */define('price-tag', { props: { price: {} }, setup });