Skip to content

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 ^ object

count 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.

Go typeAttributeMatching default in define()
string, fmt.Stringerthe texta string
bool"true" / "false"a boolean
intint64, uintuint64decimala number
float32, float64shortest round-tripping forma number
time.TimeRFC 3339a string
time.Durationwhole millisecondsa number
slices, maps, structs, json.MarshalerJSONan object or array
nil, nil pointer, nil slice/mapomittedanything
Scalars
return alacris.E("x-demo").
Prop("label", "hello").
Prop("count", 42).
Prop("ratio", 1.5).
Prop("open", true).
Prop("shut", false)
renders verified by go test
<x-demo label="hello" count="42" ratio="1.5" open="true" shut="false"></x-demo>
which the browser turns into the real runtime, upgrading that markup

Booleans are always written out, never signalled by presence.

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.

Composites
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)
renders verified by go test
<x-demo
tags="[&#34;a&#34;,&#34;b&#34;]"
origin="{&#34;x&#34;:1,&#34;y&#34;:2}"
lookup="{&#34;a&#34;:1,&#34;b&#34;:2}"
at="2026-08-08T12:00:00Z"
timeout="1500"
></x-demo>
which the browser turns into the real runtime, upgrading that markup

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.

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.

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 string

It 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.

Interpolated values are attribute values, never markup. There is nothing to escape by hand and no way to forget:

Hostile input, harmless output
// 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>"})
renders verified by go test
<x-demo
label="&#34; onload=&#34;alert(1)"
payload="{&#34;html&#34;:&#34;\u003c/x-demo\u003e\u003cscript\u003e&#34;}"
></x-demo>
which the browser turns into the real runtime, upgrading that markup

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.

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"}) // same

To 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:

Terminal window
alacris-go generate ./web -o ./internal/components -optional=pointer
components.UserCard(components.UserCardProps{Name: ptr("")}) // unambiguous, more typing

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());
PropAttribute
namename
maxCountmax-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.

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 });