Plushie's type system validates, coerces, and encodes widget field values. Types generate guards for setter function heads, typespecs for documentation, and cast functions for runtime validation. Every widget field declaration references a type, either a primitive shortcut or a module implementing Plushie.Type.

For the behaviour API and macro details, see Plushie.Type.

Built-in types

Primitive shortcuts

ShortcutModuleGuardAccepts
:integerPlushie.Type.Integeris_integer(v)Integers only
:floatPlushie.Type.Floatis_number(v)Integers and floats (GUI-friendly)
:stringPlushie.Type.Stringis_binary(v) or is_atom(v)Strings and atoms (coerced to string)
:booleanPlushie.Type.Booleanis_boolean(v)true or false
:atomPlushie.Type.Atomis_atom(v)Any atom
:anyPlushie.Type.Any(none)Any term
:mapPlushie.Type.Mapis_map(v)Any map

:float accepts integers at the API boundary because GUI dimensions like size: 14 should work without requiring 14.0. The guard is is_number and the value is stored as-is.

:string accepts atoms and coerces them to strings via Atom.to_string/1. This is common for enum-like string props (cursor names, mode strings) where atoms are more ergonomic.

Domain types

These are full modules referenced by name in field declarations:

ModulePurpose
Plushie.Type.ColorCSS named colors, hex strings, RGB maps
Plushie.Type.Length:fill, :shrink, {:fill_portion, n}, or numeric
Plushie.Type.Font:default, :monospace, string, or Font struct
Plushie.Type.PaddingUniform number, {v, h} tuple, or per-side map
Plushie.Type.StylePreset atoms (:primary, etc.) or StyleMap struct
Plushie.Type.Range{min, max} numeric tuple
Plushie.Type.BorderBorder struct (color, width, radius)
Plushie.Type.ShadowShadow struct (color, offset, blur)
Plushie.Type.A11yAccessibility annotations (role, label, etc.)
Plushie.Type.Alignment:left, :center, :right, :top, :bottom
Plushie.Type.LineHeightRelative multiplier, %{relative: n}, or %{absolute: n}
Plushie.Type.Shaping:basic, :advanced, :auto
Plushie.Type.Wrapping:none, :word, :glyph, :word_or_glyph

The Plushie.Type behaviour

Required callbacks

CallbackSignaturePurpose
cast/1term() -> {:ok, term()} | :errorValidate and coerce input to canonical form
typespec/0-> Macro.t()Quoted typespec for @type and @spec generation

Optional callbacks

CallbackSignaturePurpose
castable/0-> Macro.t()Quoted typespec for values cast/1 accepts (defaults to typespec/0)
constrain_guard/2(Macro.t(), keyword()) -> [Macro.t()]Additional guard clauses from field constraints
decode/1term() -> {:ok, term()} | :errorWire decoding (JSON values to canonical form, defaults to cast/1)
encode/1term() -> term()Wire encoding (Elixir value to JSON-safe value)
field_options/0-> [atom()]Valid constraint names for this type
fields/0-> [{atom(), term()}] | nilSub-field definitions for DSL block support
guard/1Macro.t() -> Macro.t() | nilQuoted guard clause for setter function heads
merge/2(term(), term()) -> term()Merge default with override (defaults to replacement)
resolve/2(term(), map()) -> term()Derive value from sibling widget props (defaults to identity)

How types are used

When you declare field :size, :float, min: 0, the widget macro resolves :float to Plushie.Type.Float at compile time and:

  1. Typespec: calls Float.typespec() to generate the field's @type entry
  2. Castable: calls Float.castable() to generate the setter's @spec (what the setter accepts as input)
  3. Guard: calls Float.guard(var) to generate the setter's when is_number(value) clause
  4. Constraints: calls Float.constrain_guard(var, [min: 0]) to add and value >= 0 to the guard
  5. Cast: calls Float.cast(value) in the setter body to validate and coerce user input
  6. Encode: calls Float.encode(value) during tree normalization to produce the wire-safe representation sent to the renderer
  7. Decode: calls Float.decode(value) when event data arrives from the renderer, coercing wire-format values back to Elixir
  8. Doc generation: uses the typespec string in the auto-generated Props table in the widget's @moduledoc

If cast returns :error, the setter raises ArgumentError with the field name and the invalid value.

Declaring custom types

There are four ways to declare a type: enum, struct, union, and manual implementation. The first three use the use Plushie.Type macro which generates callbacks from declarations. Manual implementation gives full control.

Enum types

For types with a fixed set of atom values:

defmodule MyApp.Type.Priority do
  use Plushie.Type

  enum [:low, :medium, :high, :critical]
end

The macro generates:

  • cast/1: returns {:ok, value} if the atom is in the list, :error otherwise
  • guard/1: value in [:low, :medium, :high, :critical]
  • typespec/0: :low | :medium | :high | :critical

  • encode/1: Atom.to_string(value)

Use it in a widget. The :doc option provides the description that appears in the auto-generated Props table and the setter's @doc:

widget :task_card do
  field :priority, MyApp.Type.Priority, doc: "Task priority level."
end

See Custom Widgets: Field options for all available field options (:doc, :default, :wire_name, :cast, etc.) and reserved field names.

The setter accepts only the declared atoms:

TaskCard.priority(card, :high)      # OK
TaskCard.priority(card, :unknown)   # raises ArgumentError

Struct types

For types with multiple named fields, wrap field declarations in a struct do ... end block:

defmodule MyApp.Type.Margin do
  use Plushie.Type

  struct do
    field :top, :float
    field :right, :float
    field :bottom, :float
    field :left, :float
  end
end

The macro generates a struct and:

  • cast/1: accepts a map or keyword list, validates each field through its type, returns {:ok, %Margin{...}}
  • encode/1: converts struct to plain map (strips __struct__ and nil fields, encodes values recursively)
  • fields/0: [top: :float, right: :float, ...] for DSL block resolution
  • typespec/0: %MyApp.Type.Margin{}
  • guard/1: is_struct(value, MyApp.Type.Margin)

Struct types support the DSL block form:

container "panel" do
  margin do
    top 10
    bottom 20
  end
end

Union types

For types that accept multiple forms. Order matters: cast tries each variant top-to-bottom and returns the first success.

defmodule MyApp.Type.Background do
  use Plushie.Type

  union do
    enum [:transparent, :inherit]
    type Plushie.Type.Color
    type Plushie.Type.Gradient
  end
end

Given Background.cast(:transparent):

  1. Tries enum cast: :transparent is in the list, returns {:ok, :transparent}

Given Background.cast("#ff0000"):

  1. Tries enum cast: "#ff0000" is not an atom in the list, fails
  2. Tries Color.cast: valid hex string, returns {:ok, "#ff0000"}

Given Background.cast(42):

  1. Tries enum: fails
  2. Tries Color: fails
  3. Tries Gradient: fails
  4. Returns :error

The guard is the OR of each variant's guard. The typespec is the union of each variant's typespec.

Overlapping types: if :transparent is both a valid enum atom AND a valid Color name, the enum match wins because it's listed first. Place more specific types before more general ones.

Manual types

For types with complex validation logic, implement the callbacks directly. You still start with use Plushie.Type:

defmodule MyApp.Type.HexColor do
  use Plushie.Type

  @impl Plushie.Type
  def cast("#" <> hex = value) when byte_size(hex) in [6, 8] do
    if String.match?(hex, ~r/^[0-9a-fA-F]+$/) do
      {:ok, String.downcase(value)}
    else
      :error
    end
  end
  def cast(_), do: :error

  @impl Plushie.Type
  def typespec, do: quote(do: String.t())

  @impl Plushie.Type
  def guard(var), do: quote(do: is_binary(unquote(var)))
end

Writing cast/1

cast/1 is the heart of a type module. It defines what values are accepted and how they're normalized.

Contract

  • Return {:ok, canonical_value} on valid input
  • Return :error on invalid input
  • Never raise (the framework converts :error to ArgumentError with context)
  • Be idempotent: cast(value) |> elem(1) |> cast() must succeed

Multiple input forms

Types often accept several input representations and normalize to a canonical form. Plushie.Type.Color is a good example:

defmodule Plushie.Type.Color do
  use Plushie.Type

  # Named atoms: :red, :blue, :cornflowerblue, ...
  def cast(name) when is_atom(name) do
    case Map.fetch(@named_colors, name) do
      {:ok, hex} -> {:ok, hex}
      :error -> :error
    end
  end

  # Hex strings: "#ff0000", "#ff000080"
  def cast("#" <> _ = hex), do: {:ok, String.downcase(hex)}

  # RGB maps: %{r: 255, g: 0, b: 0}
  def cast(%{r: r, g: g, b: b}) when is_integer(r) and is_integer(g) and is_integer(b) do
    {:ok, "#" <> hex(r) <> hex(g) <> hex(b)}
  end

  def cast(_), do: :error
end

All three forms normalize to a canonical hex string. The setter accepts any of them:

Button.background(btn, :red)           # OK, stored as "#ff0000"
Button.background(btn, "#FF0000")      # OK, stored as "#ff0000"
Button.background(btn, %{r: 255, g: 0, b: 0})  # OK, stored as "#ff0000"
Button.background(btn, 42)             # raises ArgumentError

Delegating to other types

Types can compose internally by calling other types' cast/1:

defmodule MyApp.Type.ColorPair do
  use Plushie.Type

  def cast(%{fg: fg, bg: bg}) do
    with {:ok, fg_hex} <- Plushie.Type.Color.cast(fg),
         {:ok, bg_hex} <- Plushie.Type.Color.cast(bg) do
      {:ok, %{fg: fg_hex, bg: bg_hex}}
    else
      _ -> :error
    end
  end
  def cast(_), do: :error
end

Writing guard/1

Guards run in setter function heads before cast. They reject obviously wrong types at pattern match time (faster than calling cast). Guards must use only guard-safe expressions (BIF calls like is_binary, is_number, comparisons, boolean operators).

@impl Plushie.Type
def guard(var) do
  quote(do: is_binary(unquote(var)) or is_atom(unquote(var)))
end

The var argument is a quoted variable reference. Return nil to skip guard generation entirely (the setter accepts all values and relies on cast for validation). This is appropriate when the type accepts too many input forms to express in a guard.

Note: the guard protects the setter's main clause. A separate nil clause (for optional fields) is generated automatically. Don't include nil checking in your guard.

Writing typespec/0

Return a quoted Elixir typespec AST. This appears in the widget's @type t definition and setter @spec:

# Simple: a known Elixir type
def typespec, do: quote(do: String.t())

# Union: multiple forms
def typespec, do: quote(do: atom() | String.t() | map())

# Module struct reference
def typespec, do: quote(do: %MyApp.Type.Margin{})

The typespec should describe the INPUT forms the type accepts (what users write in code), not the internal canonical form (what cast produces). This gives accurate @spec annotations on setters.

Writing encode/1

encode/1 converts the canonical Elixir value to a JSON-safe wire representation. Called during tree normalization for every non-nil prop value.

@impl Plushie.Type
def encode(%__MODULE__{top: t, right: r, bottom: b, left: l}) do
  %{top: t, right: r, bottom: b, left: l}
end

When to implement encode/1:

  • Your type stores structs (need to convert to plain maps)
  • Your type stores atoms (need to convert to strings)
  • Your type stores tuples (need to convert to lists)

When to skip encode/1 (the default handles it):

  • Your type stores strings, numbers, or booleans (already JSON-safe)
  • Your type stores plain maps (recursively encoded by the framework)

The framework's default encoding (Plushie.Type.encode_value/1) handles common cases: atoms become strings, tuples become lists, maps are recursively encoded, structs delegate to their module's encode/1 if available.

For types with nested sub-values, call Plushie.Type.encode_value/1 on them:

def encode(%__MODULE__{items: items, label: label}) do
  %{
    items: Enum.map(items, &Plushie.Type.encode_value/1),
    label: label
  }
end

Composite types

Types can be composed inline in field declarations without a dedicated module:

widget :chart do
  field :data_points, {:list, MyApp.Type.DataPoint}
  field :range, {:tuple, [:float, :float]}
  field :mode, {:enum, [:line, :bar, :scatter]}
  field :scores, {:map, {:string, :integer}}
  field :dimensions, {:map, [width: :float, height: :float]}
  field :background, {:union, [Plushie.Type.Color, Plushie.Type.Gradient]}
end

How composite validation works

ConstructorGuardCast
{:enum, [atoms]}v in [atoms]Checks membership.
{:list, inner}is_list(v)Maps inner.cast/1 over each element. All must succeed.
{:map, {K, V}}is_map(v) or is_list(v)Casts every key through K and every value through V.
{:map, [name: type]}is_map(v) or is_list(v)Casts each named field through its type. Accepts maps and keyword lists. Missing fields become nil.
{:tuple, [types]}is_tuple(v) and tuple_size(v) == NValidates each position through its type. All must succeed.
{:union, [types]}(none)Tries each type's cast in order. First {:ok, _} wins.

If any element fails to cast in a list or tuple, the entire setter raises ArgumentError.

Nested composites

Composites nest naturally:

# List of coordinate tuples
field :path, {:list, {:tuple, [:float, :float]}}

# List of enum values
field :selected_tags, {:list, {:enum, [:bug, :feature, :docs]}}

The setter validates the outer list, then validates each element through the inner composite.

When to use a module vs inline composite

Use inline composites when the type is simple and used in one place:

field :mode, {:enum, [:line, :bar, :scatter]}

Use a module when:

  • The type has complex cast logic (multiple input forms)
  • The type is reused across multiple widgets
  • The type needs custom encoding
  • The type has constraints (field_options/constrain_guard)

Field constraints

Numeric and string types can declare constraint support. The widget macro validates constraints at compile time and extends the setter guard:

field :opacity, :float, min: 0.0, max: 1.0
field :name, :string, min_length: 1
field :count, :integer, min: 0, max: 999
TypeConstraintsGuard extension
:integermin, maxvalue >= min and value <= max
:floatmin, maxvalue >= min and value <= max
:stringmin_length, max_lengthbyte_size(value) >= n

Custom constraints

Define your own constraints by implementing field_options/0 and constrain_guard/2:

defmodule MyApp.Type.Port do
  use Plushie.Type

  @impl Plushie.Type
  def cast(v) when is_integer(v) and v > 0 and v <= 65535, do: {:ok, v}
  def cast(_), do: :error

  @impl Plushie.Type
  def typespec, do: quote(do: pos_integer())

  @impl Plushie.Type
  def guard(var), do: quote(do: is_integer(unquote(var)))

  @impl Plushie.Type
  def field_options, do: [:exclude_reserved]

  @impl Plushie.Type
  def constrain_guard(var, opts) do
    if opts[:exclude_reserved] do
      [quote(do: unquote(var) > 1024)]
    else
      []
    end
  end
end

Usage:

field :port, MyApp.Type.Port, exclude_reserved: true

The macro checks at compile time that exclude_reserved is in the type's field_options() list. Unknown constraints raise a compile error.

Types in event declarations

The same types work in event field declarations:

event :color_changed do
  field :hue, :float
  field :saturation, :float
end

event :item_selected, value: MyApp.Type.Priority

Event fields are decoded at the wire boundary when events arrive from the renderer, using Plushie.Type.decode_field/2. This calls the type's decode/1 callback, which handles wire-format coercion (e.g., converting JSON strings to atoms for enum types). Invalid values are dropped with a protocol error.

Nil handling

Types never receive nil. The framework handles nil before calling cast:

  • required: true fields (default for positional args): nil at construction time raises ArgumentError
  • Optional fields: nil in a setter stores nil (unsets the field)
  • Nil fields are skipped during wire encoding (renderer uses its default)

Your cast/1 does not need a cast(nil) clause.

Module organization

Place type modules under a Type namespace in your application:

lib/
  my_app/
    type/
      priority.ex       # MyApp.Type.Priority
      color_pair.ex     # MyApp.Type.ColorPair
      margin.ex         # MyApp.Type.Margin

Type modules are discovered by the widget macro at compile time via Code.ensure_compiled/1. They must be compilable before the widget module that references them.

Testing

Cast validation

Test your type's cast/1 with valid inputs, invalid inputs, and edge cases:

describe "Priority" do
  test "accepts valid values" do
    assert {:ok, :high} = MyApp.Type.Priority.cast(:high)
    assert {:ok, :low} = MyApp.Type.Priority.cast(:low)
  end

  test "rejects invalid values" do
    assert :error = MyApp.Type.Priority.cast(:invalid)
    assert :error = MyApp.Type.Priority.cast("high")
    assert :error = MyApp.Type.Priority.cast(nil)
  end

  test "is idempotent" do
    {:ok, value} = MyApp.Type.Priority.cast(:high)
    assert {:ok, ^value} = MyApp.Type.Priority.cast(value)
  end
end

Multi-form cast

For types accepting multiple input forms, test each form and verify they produce the same canonical output:

test "all color forms normalize to hex" do
  {:ok, from_atom} = Color.cast(:red)
  {:ok, from_hex} = Color.cast("#ff0000")
  {:ok, from_map} = Color.cast(%{r: 255, g: 0, b: 0})

  assert from_atom == from_hex
  assert from_hex == from_map
  assert from_atom == "#ff0000"
end

Encode round-trip

Verify encoded output is JSON-safe and preserves information:

test "encode produces JSON-safe output" do
  {:ok, value} = MyApp.Type.Margin.cast(%{top: 10, bottom: 20})
  encoded = MyApp.Type.Margin.encode(value)

  assert is_map(encoded)
  assert {:ok, _} = Jason.encode(encoded)
  assert encoded.top == 10
  assert encoded.bottom == 20
end

Widget integration

Test that widgets using your type validate correctly:

test "setter accepts valid values" do
  card = TaskCard.new("t1") |> TaskCard.priority(:high)
  assert card.priority == :high
end

test "setter rejects invalid values" do
  assert_raise ArgumentError, fn ->
    TaskCard.new("t1") |> TaskCard.priority(:invalid)
  end
end

See also