Skip to content

2p4b/blueprint

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

74 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Draft

Hex.pm License

Draft is an Elixir library for building typed structs with built-in type coercion and validation. Define schemas with type safety, automatic casting, and flexible validation rules.

Installation

Add draft to your dependencies in mix.exs:

def deps do
    [
        {:draft, "~> 1.1.1"}
    ]
end

Quick Start

defmodule User do
    use Draft.Schema

    schema required: true do
        field :id,    :uuid
        field :name,  :string, min: 1, max: 100
        field :email, :string, pattern: :email
        field :age,   :integer, min: 0
    end
end

# Create a struct, raises on error
user = User.new!(
  id: "550e8400-e29b-41d4-a716-446655440000",
  name: "Alice",
  email: "alice@example.com",
  age: 30
)

# Create with result tuple
{:ok, user} = User.cast(%{
  "id" => "550e8400-e29b-41d4-a716-446655440000",
  "name" => "Alice",
  "email" => "alice@example.com",
  "age" => "30"
})

# Validate
[] = Draft.errors(user)  # No errors

Defining Schemas

Use Draft.Schema to define typed structs:

defmodule Book do
    use Draft.Schema

    schema do
        field :title,     :string
        field :author,    :string
        field :isbn,      :integer
        field :published, :datetime
    end
end

Required Fields

By default, all fields are optional (can be nil). Use required: true at the schema level to make all fields required:

schema required: true do
    field :id,    :uuid
    field :name,  :string
    field :email, :string
end

Or mark individual fields as required:

schema do
    field :id,    :uuid, required: true
    field :name,  :string
    field :notes, :string  # optional
end

Fields with default values are automatically optional:

field :status, :string, default: "pending"

Construction

new!/1

Creates a struct, raising ArgumentError on invalid types or missing required fields:

# From keyword list
book = Book.new!(title: "Elixir in Action", author: "Sasa Juric", isbn: 1234567890)

# From map
book = Book.new!(%{title: "Elixir in Action", author: "Sasa Juric", isbn: 1234567890})

# String keys are automatically converted
book = Book.new!(%{"title" => "Elixir in Action", "author" => "Sasa Juric"})

cast/1

Returns a result tuple without raising:

{:ok, book} = Book.cast(title: "Elixir in Action", author: "Sasa Juric")
{:error, reason} = Book.cast(title: 123)  # Type coercion error

from_struct/2

Creates a struct from another struct, useful for transforming between similar types:

defmodule Document do
    use Draft.Schema
    schema do
        field :title, :string
        field :body,  :string
        field :meta,  :map
    end
end

defmodule Article do
    use Draft.Schema
    schema do
        field :title,   :string
        field :content, :string
    end
end

doc = Document.new(title: "Hello", body: "World", meta: %{})

# Direct conversion (matching field names)
article = Article.from_struct(doc)

# With field remapping
article = Article.from_struct(doc, content: :body)

Returns the struct on success or {:error, reason} on failure.

Type Coercion

Draft automatically coerces values to the correct type during construction:

defmodule Example do
    use Draft.Schema
    schema do
        field :count,  :integer
        field :price,  :float
        field :active, :boolean
    end
end

# String values are coerced
Example.new(count: "42", price: "19.99", active: "true")
# => %Example{count: 42, price: 19.99, active: true}

Validation

Validation is separate from construction. Use Draft.validate/1 or Draft.errors/1 to validate a struct:

defmodule Product do
    use Draft.Schema
    schema do
        field :name,  :string, min: 1, max: 100
        field :price, :number, min: 0
        field :sku,   :string, pattern: ~r/^[A-Z]{3}-\d{4}$/
    end
end

product = Product.new(name: "", price: -10, sku: "invalid")

# Get validation errors
errors = Draft.errors(product)
# => [
#   name: "must be greater than 1",
#   price: "must be greater than 0",
#   sku: "does not match the required format"
# ]

# Check if valid
Draft.valid?(product)  # => false

# Validate with result tuple
{:error, errors} = Draft.validate(product)

Built-in Validators

Validator Options Description
required true Field must not be nil
min integer Minimum value (numbers) or length (strings/lists)
max integer Maximum value (numbers) or length (strings/lists)
length min:, max:, is:, in: Exact length constraints
format :email, :url, or regex String format validation
pattern regex Custom regex pattern
inclusion list or in: Value must be in list
exclusion list or in: Value must not be in list
by function Custom validation function
uuid true Valid UUID format
tld true Valid top-level domain

Validation Examples

# Length validation
field :username, :string, length: [min: 3, max: 20]
field :pin,      :string, length: [is: 4]
field :code,     :string, length: [in: 6..10]

# Numeric bounds
field :age,   :integer, min: 0, max: 150
field :score, :number,  min: 0, max: 100

# Pattern matching
field :phone, :string, pattern: ~r/^\+?[\d\s-]+$/
field :email, :string, format: :email

# Inclusion/Exclusion
field :status, :string, inclusion: ["pending", "active", "closed"]
field :role,   :atom,   exclusion: [:admin, :superuser]

# Custom validation
field :even_number, :integer, by: fn val -> rem(val, 2) == 0 end

Custom Error Messages

Validators accept a :message option for custom error messages with EEx templating:

field :age, :integer, min: [min: 18, message: "must be at least <%= min %> years old"]
field :name, :string,
  length: [min: 2, message: "<%= value %> is too short (min <%= min %> chars)"]

Conditional Validation

Skip validation based on conditions:

# Skip if value is nil
field :nickname, :string, min: [min: 3, allow_nil: true]

# Skip if value is blank (nil or empty string)
field :bio, :string, length: [max: 500, allow_blank: true]

Built-in Types

Type Description Coerces From
:string Text values Any value via to_string/1
:integer Whole numbers Strings, floats
:float Decimal numbers Strings, integers
:number Any numeric value Strings
:boolean True/false "true", "false", 1, 0
:atom Atoms Strings (existing atoms only)
:uuid UUID strings Strings
:datetime DateTime structs ISO8601 strings
:map Maps -
:list Lists -
:tuple Tuples -
:enum Enumerated values Strings, atoms
:struct Struct types Maps
:any Any value -

Advanced Features

Nested Schemas

Use Draft schemas as field types:

defmodule Address do
    use Draft.Schema
    schema do
        field :street,  :string
        field :city,    :string
        field :country, :string
    end
end

defmodule Person do
    use Draft.Schema
    schema do
        field :name,    :string
        field :address, Address
    end
end

Person.new(
  name: "Alice",
  address: %{street: "123 Main St", city: "Boston", country: "USA"}
)

Lists of Schemas

defmodule Order do
    use Draft.Schema
    schema do
        field :items, :list, type: LineItem, default: []
    end
end

Enum Types

field :status, :enum, values: [:pending, :processing, :shipped, :delivered]

Map Fields with Schema

Define typed map fields without creating a separate module:

defmodule Report do
    use Draft.Schema

    @metadata_schema [
        author:    [:string, required: true],
        version:   [:integer, min: 1],
        tags:      [:list, type: :string]
    ]

    schema do
        field :title,    :string
        field :metadata, :map, fields: @metadata_schema
    end
end

Inheritance

Extend existing schemas with the :extends option:

defmodule Entity do
    use Draft.Schema
    schema do
        field :id,         :uuid
        field :created_at, :datetime
        field :updated_at, :datetime
    end
end

defmodule User do
    use Draft.Schema
    schema extends: Entity do
        field :name,  :string
        field :email, :string
    end
end

# User has all fields from Entity plus its own:
# %User{id: nil, created_at: nil, updated_at: nil, name: nil, email: nil}
user = User.new(
  id: "550e8400-e29b-41d4-a716-446655440000",
  name: "Alice",
  email: "alice@example.com"
)

Inheriting required fields:

If the parent schema is defined with required: true, child schemas inherit that enforcement:

defmodule Entity do
    use Draft.Schema
    schema required: true do
        field :id,         :uuid
        field :created_at, :datetime
    end
end

defmodule Post do
    use Draft.Schema
    schema extends: Entity do
        field :title, :string, required: true
        field :body,  :string  # optional
    end
end

# :id, :created_at, and :title are all required
Post.new(title: "Hello")  # raises — :id and :created_at are missing

Multi-level inheritance:

defmodule Timestamps do
    use Draft.Schema
    schema do
        field :created_at, :datetime
        field :updated_at, :datetime
    end
end

defmodule Entity do
    use Draft.Schema
    schema extends: Timestamps do
        field :id, :uuid
    end
end

defmodule User do
    use Draft.Schema
    schema extends: Entity do
        field :name,  :string
        field :email, :string
    end
end

# User inherits from Entity which inherits from Timestamps:
# %User{created_at: nil, updated_at: nil, id: nil, name: nil, email: nil}

Multiple inheritance:

defmodule Timestamps do
    use Draft.Schema
    schema do
        field :created_at, :datetime
        field :updated_at, :datetime
    end
end

defmodule SoftDelete do
    use Draft.Schema
    schema do
        field :deleted_at, :datetime
    end
end

defmodule Post do
    use Draft.Schema
    schema extends: [Timestamps, SoftDelete] do
        field :title, :string
        field :body,  :string
    end
end

# Post has: created_at, updated_at, deleted_at, title, body

Overwriting inherited fields:

Use overwrite: true on a field to replace an inherited field's type or validators:

defmodule User do
    use Draft.Schema
    schema extends: Entity do
        field :name,  :string
        field :email, :string  # no format validation
    end
end

defmodule Admin do
    use Draft.Schema
    schema extends: User do
        # Replace the inherited :email with a stricter version
        field :email, :string, overwrite: true, format: :email
    end
end

admin = Admin.new(name: "Bob", email: "not-an-email")
Draft.valid?(admin)  # => false — format: :email is now enforced

Serialization (Dump)

Convert structs back to plain maps:

user = User.new(name: "Alice", email: "alice@example.com")
{:ok, map} = User.dump(user)
# => {:ok, %{"name" => "Alice", "email" => "alice@example.com"}}

Custom Types

Implement Draft.Type.Behaviour for custom types:

defmodule MyApp.Types.Money do
    @behaviour Draft.Type.Behaviour

    @impl true
    def cast(value, _opts) when is_integer(value) do
        {:ok, Decimal.new(value)}
    end

    def cast(value, _opts) when is_binary(value) do
    case Decimal.parse(value) do
        {decimal, ""} -> {:ok, decimal}
        _ -> {:error, ["invalid money format"]}
    end
    end

    def cast(_, _), do: {:error, ["invalid money format"]}

    @impl true
    def dump(value, _opts) do
        {:ok, Decimal.to_string(value)}
    end
end

Custom Validators

Implement Draft.Validator.Behaviour:

defmodule MyApp.Validators.Positive do
    use Draft.Validator

    def validate(value, _opts) when is_number(value) and value > 0 do
        {:ok, value}
    end

    def validate(_value, opts) do
        {:error, message(opts, "must be positive")}
    end
end

Configuration

Register custom types and validators in config/config.exs:

config :draft, :types,
    money: MyApp.Types.Money

config :draft, :validators,
    positive: MyApp.Validators.Positive

Then use them in schemas:

field :amount, :money, positive: true

API Reference

Schema Functions

Function Description
new!/1 Create struct, raises on error
new/1 Deprecated alias for new!/1
cast/1 Create struct, returns result tuple
from_struct/2 Create from another struct with optional field remapping
dump/1 Serialize struct to map

Draft Functions

Function Description
Draft.valid?/1 Check if struct is valid
Draft.validate/1 Validate and return result tuple
Draft.errors/1 Get list of validation errors

License

MIT License - see LICENSE for details.

About

elixir schema and validation lib

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages