Search documentation of Plushie

# Custom Types

Copy Markdown View Source

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

Shortcut | Module | Guard | Accepts
--- | --- | --- | ---
`:integer` | `Plushie.Type.Integer` | `is_integer(v)` | Integers only
`:float` | `Plushie.Type.Float` | `is_number(v)` | Integers and floats (GUI-friendly)
`:string` | `Plushie.Type.String` | `is_binary(v) or is_atom(v)` | Strings and atoms (coerced to string)
`:boolean` | `Plushie.Type.Boolean` | `is_boolean(v)` | `true` or `false`
`:atom` | `Plushie.Type.Atom` | `is_atom(v)` | Any atom
`:any` | `Plushie.Type.Any` | (none) | Any term
`:map` | `Plushie.Type.Map` | `is_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:

Module | Purpose
--- | ---
`Plushie.Type.Color` | CSS 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.Padding` | Uniform number, `{v, h}` tuple, or per-side map
`Plushie.Type.Style` | Preset atoms (`:primary`, etc.) or `StyleMap` struct
`Plushie.Type.Range` | `{min, max}` numeric tuple
`Plushie.Type.Border` | Border struct (color, width, radius)
`Plushie.Type.Shadow` | Shadow struct (color, offset, blur)
`Plushie.Type.A11y` | Accessibility annotations (role, label, etc.)
`Plushie.Type.Alignment` | `:left`, `:center`, `:right`, `:top`, `:bottom`
`Plushie.Type.LineHeight` | Relative 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

Callback | Signature | Purpose
--- | --- | ---
`cast/1` | `term() -> {:ok, term()} | :error` | Validate and coerce input to canonical form
`typespec/0` | `-> Macro.t()` | Quoted typespec for `@type` and `@spec` generation

### Optional callbacks

Callback | Signature | Purpose
--- | --- | ---
`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/1` | `term() -> {:ok, term()} | :error` | Wire decoding (JSON values to canonical form, defaults to `cast/1`)
`encode/1` | `term() -> term()` | Wire encoding (Elixir value to JSON-safe value)
`field_options/0` | `-> [atom()]` | Valid constraint names for this type
`fields/0` | `-> [{atom(), term()}] | nil` | Sub-field definitions for DSL block support
`guard/1` | `Macro.t() -> Macro.t() | nil` | Quoted 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

Constructor | Guard | Cast
--- | --- | ---
`{: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) == N` | Validates 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

Type | Constraints | Guard extension
--- | --- | ---
`:integer` | `min`, `max` | `value >= min and value <= max`
`:float` | `min`, `max` | `value >= min and value <= max`
`:string` | `min_length`, `max_length` | `byte_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

- `Plushie.Type` - behaviour definition and `use Plushie.Type` macro
- Custom Widgets reference - using types in widget field declarations
- Built-in Widgets reference - type usage in built-in widget props

← Previous Page Custom Canvas Elements
Next Page → Custom Widgets