solnic

solnic

đź’¦ Drops - Simplifying Data Validation in Elixir

Hello!

Today I released the first version of Elixir Drops :slight_smile: The announcement can be found on my blog but I’m also just pasting it here:

A few years ago my Ruby friends asked me if it would be possible to port some of the dry-rb libraries to Elixir. I remember some early community attempts at porting dry-validation specifically, I did my best to support the effort but back then my Elixir knowledge was too basic and I was too busy with other work.

Fast forward to today and I’m happy to announce the very first release of Elixir Drops! :tada: In this article I’ll introduce you to the concepts of the library and show you how you could use it.

Contracts with schemas

One of the core features of Drops is its Contract extension with the Schema DSL. It allows you to define the exact shape of the data that your system relies on and data domain validation rules that are applied to type-safe data that was processed by the schema.

There are multiple benefits of this approach:

  • Casting and validating data using schemas is very precise, producing detailed error messages when things go wrong

  • Schemas give you type safety at the boundaries of your system - you can apply a schema to external input and be 100% sure it’s safe to work with

  • It’s very easy to see the shape of data, making it easier to reason about your system as a whole

  • You can restrict and limit larger data structures, reducing them to simpler representations that your system needs

  • It’s easy to convert schemas to other representations ie for documentation purposes or export them to JSON Schema format

  • Schemas capture both the structure and the types of data, making it easy to reuse them across your codebase

  • Your domain validation rules become simple and focused on their essential logic as they apply to data that meets type requirements enforced by schemas

This of course sounds very abstract, so here’s a simple example of a data contract that defines a schema for a new user:

defmodule Users.Signup do
  use Drops.Contract

  schema do
    %{
      required(:name) => string(:filled?),
      optional(:age) => integer()
    }
  end
end

Users.Signup.conform(%{
  name: "Jane Doe",
  age: 42
})
# {:ok,
#  %{
#    name: "Jane Doe",
#    age: 42
#  }}

{:error, errors} = Users.Signup.conform(%{})

Enum.map(errors, &to_string/1)
# ["name key must be present"]

{:error, errors} = Users.Signup.conform(%{
  name: "",
  age: "42"
})

Enum.map(errors, &to_string/1)
# ["age must be an integer", "name must be filled"]

Let’s take a closer look at what we did here:

  • The contract defines a schema with two keys:

    • required(:name) means that the input map is expected to have the key :name

    • string(:filled?) means that the :name must be a non-empty string

    • optional(:age) means that the input could have the key :age

    • integer() means that the :age must be an integer

Even though this is a very basic example, notice that the library does quite a lot for you - it processes the input map into a new map that includes only the keys that you specified in the schema, it checks both the keys and the values according to your specifications. When things go wrong - it gives you nice error messages making it easy to see what went wrong.

Type-safe Casting

One of the unique features of Drops Schemas is type-safe casting. Schemas breaks down the process of casting and validating data into 3 steps:

  1. Validate the original input values

  2. Apply optional casting functions

  3. Validate the output values

It’s better to explain this in code though:

defmodule Users.Signup do
  use Drops.Contract

  schema do
    %{
      required(:name) => string(:filled?),
      optional(:age) => cast(string(match?: ~r/\d+/)) |> integer()
    }
  end
end

Users.Signup.conform(%{
  name: "Jane Doe",
  age: "42"
})

{:error, errors} = Users.Signup.conform(%{
  name: "Jane Doe",
  age: ["oops"]
})

Enum.map(errors, &to_string/1)
# ["cast error: age must be a string"]

{:error, errors} = Users.Signup.conform(%{
  name: "Jane Doe",
  age: "oops"
})

Enum.map(errors, &to_string/1)
# ["cast error: age must have a valid format"]

Notice that when the age input value cannot be casted according to our specification, we get a nice “cast error” message and we immediately know what went wrong.

Domain validation rules

Contracts allow you to split data validation into schema validation and arbitrary domain validation rules that you can implement. Thanks to this approach we can focus on the essential logic in your rules as we don’t have to worry about the types of values that the rules depend on.

Let me explain this using a simple example:

defmodule Users.Signup do
  use Drops.Contract

  schema do
    %{
      required(:name) => string(:filled?),
      required(:password) => string(:filled?),
      required(:password_confirmation) => string(:filled?)
    }
  end

  rule(:password, %{password: password, password_confirmation: password_confirmation}) do
    if password != password_confirmation do
      {:error, {[:password_confirmation], "must match password"}}
    else
      :ok
    end
  end
end

Users.Signup.conform(%{
  name: "John",
  password: "secret",
  password_confirmation: "secret"
})
# {:ok, %{name: "John", password: "secret", password_confirmation: "secret"}}

{:error, errors} = Users.Signup.conform(%{
  name: "John",
  password: "",
  password_confirmation: "secret"
})

Enum.map(errors, &to_string/1)
# ["password must be filled"]

{:error, errors} = Users.Signup.conform(%{
  name: "John",
  password: "foo",
  password_confirmation: "bar"
})

Enum.map(errors, &to_string/1)
# ["password_confirmation must match password"]

Here we check whether password and password_confirmation match but first we define in our schema that they must be both non-empty strings. Notice that our domain validation rule is not applied at all if the schema validation does not pass.

As you can probably imagine, this type of logic could be easily implemented as a higher-level macro, something like validate_confirmation_of :password. This is something that I’ll most likely add to Drops in the near future.

Safe map atomization

Another very useful feature is support for atomizing input maps based on your schema definition. This means that the output map will include only the keys that you defined turned into atoms, in the case of string-based maps.

Here’s an example:

defmodule Users.Signup do
  use Drops.Contract

  schema(atomize: true) do
    %{
      required(:name) => string(:filled?),
      optional(:age) => integer()
    }
  end
end

Users.Signup.conform(%{
  "name" => "Jane Doe",
  "age" => 42
})
# {:ok, %{name: "Jane Doe", age: 42}}

About the first release

Today I’m releasing v0.1.0 of Drops and even though it’s the first release, it already comes with a lot of features! It’s already used in our backend system at valued.app, processing and validating millions of JSON payloads regularly.

It is an early stage of development though and I encourage you to test it out and provide feedback! Here are some useful links where you can learn more:

Check it out and let me know what you think!

Most Liked

solnic

solnic

Aaand 3 months later we have 0.2.0 released :slight_smile: Read more here: Elixir Drops 0.2.0 with support for custom types was released!

kwando

kwando

Greetings, I was a huge fan of your work and all the dry-rb stuff before I left ruby behind. I’ve used (abused) Ecto schemas for doing much of the same things this lib can do, but that gets kinda clunky when you starting nesting things :sweat_smile:

I will take this for a spin ASAP :slight_smile:

solnic

solnic

A list of errors can be transformed into a nested map with errors. This is how it works in dry-validation. Eventually, different error representations will be a built-in feature.

Yes the DSL could be translated into a more verbose syntax where you’d do essentially the same thing that you do when using Ecto changesets. I’m not sure if I’d be interested in maintaining such functionality but it may turn out that this would Just Work OOTB. We’ll see how it goes :slight_smile:

Phoenix and Ecto integrations will be done as part of the package, at least in the beginning. If they grow a lot, I may extract them into ecto-drops and phoenix-drops.

hst337

hst337

Great job!

So many validation libraries out there. It would be great if you wrote a comparison table, where you show how this library is different from the others, feature by feature

Adzz

Adzz

I wrote a similar library that may be interesting to you, GitHub - Adzz/data_schema: Declarative schemas for data transformations. it’s attempting the same idea - casting of data at the boundary so it errors nice and early on unexpected input.

@solnic thanks for sharing I’m always interested in how others approach this problem.

Where Next?

Popular in Libraries Top

wojtekmach
Hey everyone! Req is an HTTP client for Elixir that I’ve been working on for quite some time. There is already a lot of HTTP clients out...
New
markmark206
simple_feature_flags is a tiny package that lets you turn features on or off based on which environment (e.g. localhost, staging, product...
New
seancribbs
Today I released a new dialyzer Mix task as the dialyzex package! At the time we started writing this task, the existing dialyzer integra...
New
woylie
I released Doggo, a collection of unstyled Phoenix components. Features Unstyled Phoenix components. Storybook that can be added to...
New
treble37
Just looking for a little feedback on a tiny helper library I built - Sometimes I find the need to convert maps with atom keys to maps...
New
aditya7iyengar
Rummage.Ecto and Rummage.Phoenix provide ways to perform Searching, Sorting and Pagination over Ecto queries and Phoenix collections. Fo...
New
danschultzer
None of the current solutions worked well for me, so I went ahead and built a user management system from scratch. This project took far...
548 27727 240
New
engineeringdept
I’ve just released the first version of Snap, an Elasticsearch client. It borrows ideas about application structure and process managemen...
New
msaraiva
Surface is an experimental library built on top of Phoenix LiveView and its new LiveComponent API that aims to provide a more declarative...
564 42633 214
New
New

Other popular topics Top

vrod
I am using the Starship cross-shell prompt – it seems pretty nice, but I get some errors: [WARN] - (starship::utils): Executing command ...
New
JakeBecker
TL;DR: I’ve just released an implementation of Microsoft’s IDE-independent Language Server Protocol for Elixir. It adds language support ...
1140 51847 244
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod -- where is this set? Thanks.
New
malloryerik
Hi, this is for people who, like me, have had some friction using .html.heex templates in VSCode. The solution seems to be, in a hyphena...
New
sacepums
Hey guys. I'm new to elixir and im really stocked about it. But I ran into a bit of problem - I need to convert a date sting, for examp...
New
nsuchy
Hi. I’ve noticed that Windows Powershell has it’s own IEX command and you cannot access Elixir’s IEX due to the conflict. This isn’t a cr...
New
chrismccord
As promised, the first release candidate of Phoenix 1.3.0 is out! This release focuses on code generators with improved project structure...
New
danschultzer
None of the current solutions worked well for me, so I went ahead and built a user management system from scratch. This project took far...
548 27727 240
New
fireproofsocks
Forgive me if this is obvious, but how does one delete a database record WITHOUT selecting it first? https://hexdocs.pm/ecto/Ecto.Repo.h...
New
baxterw3b
Hi guys, i’m new in the Elixir world, and i have to say, that i love it! i’m having some problem to understand anonymous functions with ...
New

Sub Categories:

We're in Beta

About us Mission Statement