Search documentation of Plushie

# Plushie.Type behaviour (Plushie v0.7.2)

Copy Markdown View Source

Types for widget fields and events.

Widget field declarations like `field :name, :string` or `field :color, Plushie.Type.Color` each name a type. The type determines what values a field accepts, how they appear in typespecs, and how they encode for the renderer.

The SDK includes types for common values; when your application needs something more specific, you can define your own by implementing the `Plushie.Type` behaviour.

## Primitive types

Types for basic values like numbers, strings, and booleans, represented as atoms in field declarations:

- `:integer` - `Plushie.Type.Integer`
- `:float` - `Plushie.Type.Float`
- `:string` - `Plushie.Type.String`
- `:boolean` - `Plushie.Type.Boolean`
- `:atom` - `Plushie.Type.Atom`
- `:any` - `Plushie.Type.Any`
- `:map` - `Plushie.Type.Map`

Example in a field declaration:

    field :label, :string
    field :count, :integer, default: 0

## Domain types

The SDK includes additional types beyond the primitives, referenced by module name. Some examples:

- `Plushie.Type.Color` - CSS colors (named atoms, hex strings, RGBA maps)
- `Plushie.Type.Padding` - uniform number, `{v, h}` tuple, or per-side map
- `Plushie.Type.Font` - font family, weight, style, and stretch
- `Plushie.Type.Border` - color, width, and radius
- `Plushie.Type.A11y` - accessibility annotations

Example in a field declaration:

    field :color, Plushie.Type.Color
    field :padding, Plushie.Type.Padding, default: 8

## Composite types

When you need a list of values, a set of specific atoms, or a choice between types, you can express that directly in the field declaration:

- `{:enum, [:a, :b, :c]}` - one of a fixed set of atoms
- `{:list, :string}` - list where every element matches the inner type
- `{:map, {:string, :integer}}` - map with typed keys and values
- `{:map, [name: :string, age: :integer]}` - map with specific named fields
- `{:tuple, [:float, :float]}` - fixed-size tuple with typed positions
- `{:union, [Plushie.Type.Color, Plushie.Type.StyleMap]}` - tries each type in order, first successful cast wins

Example in a field declaration:

    field :tags, {:list, :string}
    field :mode, {:enum, [:read, :write]}
    field :scores, {:map, {:string, :integer}}

## Building your own type

If the built-in types and composite types don't quite fit, you can define your own. All custom types start with `use Plushie.Type`. From there, you can either compose existing types using the DSL macros, or implement callbacks directly for more control.

### Composing with the DSL

The DSL macros let you build types from existing ones. All callbacks are generated automatically.

Enum for a fixed set of atom values:

    defmodule MyApp.Type.Priority do
      use Plushie.Type
      enum [:low, :medium, :high, :critical]
    end

Struct for grouping related fields into a single value:

    defmodule MyApp.Type.Dimensions do
      use Plushie.Type

      struct do
        field :width, :float
        field :height, :float
      end
    end

Union when a field accepts multiple forms (first match wins):

    defmodule MyApp.Type.Size do
      use Plushie.Type

      union do
        enum [:small, :medium, :large]
        type MyApp.Type.Dimensions
      end
    end

Use them in widget field declarations by module name:

    field :priority, MyApp.Type.Priority, default: :medium
    field :size, MyApp.Type.Size

### Custom callbacks

When you need custom validation, coercion from multiple input forms, or encoding logic that the DSL can't express, you can implement the callbacks directly. You still start with `use Plushie.Type`.

A type needs at least `cast/1` and `typespec/0`. Adding `guard/1` enables pattern matching in generated setters, and `encode/1` handles cases where the wire representation differs from the Elixir value:

    defmodule MyApp.Type.Percentage do
      use Plushie.Type

      # Accept integers 0..100 or floats 0.0..1.0, normalize to float
      @impl Plushie.Type
      def cast(v) when is_integer(v) and v >= 0 and v <= 100, do: {:ok, v / 100}
      def cast(v) when is_float(v) and v >= 0.0 and v <= 1.0, do: {:ok, v}
      def cast(_), do: :error

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

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

      # encode/1 not needed: floats are already wire-safe
    end

You can also delegate to other types within your callbacks. This is useful when your type wraps or combines existing types with custom logic:

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

`Plushie.Type.Integer` serves as a minimal reference. `Plushie.Type.Color` shows how to handle many input forms.

## Callbacks

Required:

- `cast/1` - validates input, returns `{:ok, normalized}` or `:error`. Defines what values are accepted and what canonical form they take.
- `typespec/0` - returns a quoted typespec (e.g. `quote(do: float())`). Used in generated `@type` and `@spec` attributes.

Optional:

- `castable/0` - returns a quoted typespec for the values `cast/1` accepts. Default: same as `typespec/0`. Implement when the input forms are broader than the canonical type (e.g., Color accepts atoms, strings, and maps but stores a hex string).
- `decode/1` - decodes a wire-format value (JSON-decoded strings, numbers, maps with string keys) into the canonical type. Default: delegates to `cast/1`. Implement when wire and Elixir representations differ (e.g., enums receive strings but store atoms).
- `encode/1` - converts to wire-safe form (atoms to strings, structs to maps). Only needed when the Elixir value isn't directly serializable.
- `guard/1` - returns a quoted guard expression. Enables pattern matching in generated setter functions.
- `fields/0` - for struct types, returns `[{name, type}, ...]`.
- `field_options/0` - declares constraint keys (e.g. `[:min, :max]`). Validated at compile time.
- `constrain_guard/2` - generates guards from field constraints (e.g. range checks from `:min`/`:max` options).
- `merge/2` - controls how a field default combines with a user override. Default: full replacement. Implement for struct types where partial updates should preserve unset fields.
- `resolve/2` - derives the final value from sibling widget props at render time. Default: identity. Implement when your type needs to read other props to compute its value.

## Summary

### Callbacks

cast(term)
castable()

Returns the quoted typespec for values accepted by `cast/1`.

constrain_guard(t, keyword)
decode(term)

Decodes a wire-format value into the canonical type.

encode(term)
field_options()
fields()
guard(t)
merge(default, override)

Merges a default value with a user-provided override.

resolve(value, props)

Resolves derived values after all widget props are set.

typespec()

### Functions

cast_composite(arg, value)

Casts a value according to a composite type constructor.

cast_field(type, value)

Casts a value according to a type identifier.

cast_named_fields(field_specs, input)

Casts a map or keyword list against a list of `{name, type}` field specs.

cast_optional_field(type, value)

Casts a named field value, treating nil as a valid absent value.

cast_value(type, value)

Universal cast entry point that handles both module types and composite types.

composite_kind?(kind)

Returns true if the given atom is a known composite kind.

composite_module(kind)

Returns the composite module for the given kind atom.

decode_field(type, value)

Decodes a wire-format event field value.

decode_named_fields(field_specs, input)

Decodes a map against a list of `{name, type}` field specs using the decode path. Like `cast_named_fields/2` but uses `decode_value` for inner types (handling wire representations).

decode_optional_field(type, value)

Decodes a named field value from wire format, treating nil as absent.

decode_value(type, value)

Decodes a wire-format value through the type's decode path.

encode_value(v)

Encodes a value to its wire-safe representation.

merge_value(type_mod, default, override)

Merges a default value with a user override using the type's merge semantics.

primitive_shortcuts()

Returns the map of primitive atom shortcuts to their type modules.

resolve(shortcut)

Resolves a type reference to a module or composite descriptor.

resolve_value(type_mod, value, props)

Resolves derived values using the type's resolve semantics.

shortcut?(name)

Returns true if the given atom is a known primitive type shortcut.

type_display_string(type)

Returns a human-readable type string for documentation.

valid_event_type?(type)

Returns true if the given type identifier is valid for event fields.

## Callbacks

# cast(term)

    @callback cast(term()) :: {:ok, term()} | :error

# castable()

(optional)

    @callback castable() :: Macro.t()

Returns the quoted typespec for values accepted by `cast/1`.

For types that normalize input (e.g., Color accepts atoms, strings, and maps but stores a hex string), this describes the broader input surface. Widget setter `@spec` annotations use this so dialyzer and documentation reflect what users can actually pass.

Defaults to `typespec/0` when not implemented, which is correct for types where the input and canonical forms are the same.

# constrain_guard(t, keyword)

(optional)

    @callback constrain_guard(
      Macro.t(),
      keyword()
    ) :: [Macro.t()]

# decode(term)

(optional)

    @callback decode(term()) :: {:ok, term()} | :error

Decodes a wire-format value into the canonical type.

Wire data arrives as JSON-decoded values (strings, numbers, booleans, lists, maps with string keys). This callback handles coercion from those wire representations to the canonical Elixir type.

Defaults to `cast/1` when not implemented, which is correct for types where wire and Elixir representations are the same (integers, floats, strings, booleans).

Types where wire and Elixir differ should implement this. For example, enums receive strings from the wire but store atoms.

# encode(term)

(optional)

    @callback encode(term()) :: term()

# field_options()

(optional)

    @callback field_options() :: [atom()]

# fields()

(optional)

    @callback fields() :: [{atom(), term()}] | nil

# guard(t)

(optional)

    @callback guard(Macro.t()) :: Macro.t() | nil

# merge(default, override)

(optional)

    @callback merge(default :: term(), override :: term()) :: term()

Merges a default value with a user-provided override.

When a widget field has a default value and the user also provides a value, this callback controls how the two combine. The default implementation replaces the default entirely with the override, which is correct for most types.

Struct types with many optional fields can implement field-level merge instead. Consider a `%Config{theme: nil, locale: nil}` type. A widget declares `default: %Config{theme: :dark}` and the user sets `config: %Config{locale: :en}`. A field-level merge produces `%Config{theme: :dark, locale: :en}` rather than discarding the default theme.

Implement this callback when your type is a struct and partial overrides should preserve unset defaults. See `Plushie.Type.A11y` for a real-world example.

# resolve(value, props)

(optional)

    @callback resolve(value :: term(), props :: map()) :: term()

Resolves derived values after all widget props are set.

Called during tree normalization with the current field value and the full props map of the widget. This lets a type compute its final value based on sibling props that aren't known at declaration time.

For example, imagine a type with a `derive_from` field that names another prop. A widget declares `field :tooltip, MyType, default: %MyType{derive_from: :label}`. When `resolve/2` runs, it looks up the `:label` prop from the props map and uses it as the tooltip value. The `derive_from` directive is cleared since it only exists to guide resolution, not to appear on the wire.

Most types don't need this. Implement it only when your type must read other widget props to compute its final value. See `Plushie.Type.A11y` for a real-world example.

# typespec()

    @callback typespec() :: Macro.t()

## Functions

# cast_composite(arg, value)

    @spec cast_composite(
      {atom(), term()},
      term()
    ) :: {:ok, term()} | :error

Casts a value according to a composite type constructor.

Dispatches to the appropriate `Plushie.Type.Composite` module based on the composite kind.

# cast_field(type, value)

    @spec cast_field(type :: term(), value :: term()) :: {:ok, term()} | :error

Casts a value according to a type identifier.

Resolves the type via `resolve/1` and delegates to the type module's `cast/1`. The `:string` type accepts `nil` (wire-format absent fields).

Returns `{:ok, value}` on success, `:error` on failure.

# cast_named_fields(field_specs, input)

    @spec cast_named_fields([{atom(), term()}], map() | keyword()) ::
      {:ok, map()} | :error

Casts a map or keyword list against a list of `{name, type}` field specs.

Looks up each field by atom key first, then by string key as a fallback. Casts through the field's type and returns `{:ok, map}` or `:error`. Missing fields become nil.

# cast_optional_field(type, value)

    @spec cast_optional_field(term(), term()) :: {:ok, term()} | :error

Casts a named field value, treating nil as a valid absent value.

Used by map record composites and struct type cast.

# cast_value(type, value)

    @spec cast_value(term(), term()) :: {:ok, term()} | :error

Universal cast entry point that handles both module types and composite types.

Resolves the type via `resolve/1` and delegates to either `cast_composite/2` or the type module's `cast/1`.

# composite_kind?(kind)

    @spec composite_kind?(atom()) :: boolean()

Returns true if the given atom is a known composite kind.

# composite_module(kind)

    @spec composite_module(atom()) :: module()

Returns the composite module for the given kind atom.

# decode_field(type, value)

    @spec decode_field(type :: term(), value :: term()) :: {:ok, term()} | :error

Decodes a wire-format event field value.

Like `cast_field/2` but uses the type's `decode/1` callback, which handles wire representations (strings for enums, lists for tuples, etc.).

# decode_named_fields(field_specs, input)

    @spec decode_named_fields([{atom(), term()}], map()) :: {:ok, map()} | :error

Decodes a map against a list of `{name, type}` field specs using the decode path. Like `cast_named_fields/2` but uses `decode_value` for inner types (handling wire representations).

# decode_optional_field(type, value)

    @spec decode_optional_field(term(), term()) :: {:ok, term()} | :error

Decodes a named field value from wire format, treating nil as absent.

# decode_value(type, value)

    @spec decode_value(term(), term()) :: {:ok, term()} | :error

Decodes a wire-format value through the type's decode path.

Like `cast_value/2` but uses `decode/1` for module types and `decode/2` for composite types.

# encode_value(v)

    @spec encode_value(term()) :: term()

Encodes a value to its wire-safe representation.

Handles primitives (atoms become strings, tuples become lists) and structs (delegates to `module.encode/1` when available, otherwise strips `__struct__` and recursively encodes the map).

# merge_value(type_mod, default, override)

    @spec merge_value(module(), term(), term()) :: term()

Merges a default value with a user override using the type's merge semantics.

Falls back to simple replacement if the type module doesn't implement merge/2.

# primitive_shortcuts()

    @spec primitive_shortcuts() :: %{required(atom()) => module()}

Returns the map of primitive atom shortcuts to their type modules.

# resolve(shortcut)

    @spec resolve(term()) :: module() | {:composite, term()}

Resolves a type reference to a module or composite descriptor.

Accepts primitive atom shortcuts (`:integer`, `:string`, etc.), module names, and composite tuple forms (`{:enum, [...]}`, `{:list, type}`, `{:map, spec}`, `{:tuple, [types]}`, `{:union, [types]}`).

# resolve_value(type_mod, value, props)

    @spec resolve_value(module(), term(), map()) :: term()

Resolves derived values using the type's resolve semantics.

Falls back to identity if the type module doesn't implement resolve/2.

# shortcut?(name)

    @spec shortcut?(atom()) :: boolean()

Returns true if the given atom is a known primitive type shortcut.

# type_display_string(type)

    @spec type_display_string(term()) :: String.t()

Returns a human-readable type string for documentation.

# valid_event_type?(type)

    @spec valid_event_type?(type :: term()) :: boolean()

Returns true if the given type identifier is valid for event fields.

Valid types are primitive shortcuts, modules implementing Plushie.Type (with `cast/1`), or composite type tuples.