Plushie.Type behaviour (Plushie v0.7.0)

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:

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:

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

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

Decodes a wire-format value into the canonical type.

Merges a default value with a user-provided override.

Resolves derived values after all widget props are set.

Functions

Casts a value according to a composite type constructor.

Casts a value according to a type identifier.

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

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

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

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

Returns the composite module for the given kind atom.

Decodes a wire-format event field value.

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

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

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

Encodes a value to its wire-safe representation.

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

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

Resolves a type reference to a module or composite descriptor.

Resolves derived values using the type's resolve semantics.

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

Returns a human-readable type string for documentation.

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.