/toy

Primary LanguageGleamApache License 2.0Apache-2.0

Toy

Practical, composable dynamic decoders and validators for Gleam!

Package Version Hex Docs

Checkout the quickstart guide to get started.

gleam add toy
import gleam/dynamic
import gleam/json
import toy

pub type User {
  User(name: String, height: Float, age: Int)
}

pub fn user_decoder() {
  use name <- toy.field("name", toy.string)
  use height <- toy.field("height", toy.float |> toy.float_min(0.0))
  use age <- toy.field("age", toy.int |> toy.int_min(0))

  toy.decoded(User(name:, height:, age:))
}

pub fn main() {
  let assert Ok(data) =
    json.decode(
      "
  {
    \"name\": \"Alice\",
    \"height\": 1.7,
    \"age\": 25
  }
  ",
      dynamic.dynamic,
    )

  let user = data |> toy.decode(user_decoder())
}

Goals

Toy aims to provide a simple to use, composable api for decoding and validating dynamic data in Gleam. It is inspired by the gleam standard library, decode and Zod.

Toy strives to satisfy all of the following goals:

  • Composable
  • Good DX
  • Type safe
  • No opaque types
  • Minimize surface for dumb mistakes
  • Return all error messages possible
  • Allow for easy internationalization of error messages
  • Provide validation functions for common use cases

Why not existing solutions?

In application development you most of the time decode dynamic values into records (objects in other lanugages). It might be a JSON payload from an API, a configuration file, or a database row. So this will be the main focus of this comparison.

Gleam standard library gleam_stdlib/dynamic

pub fn user_decoder_stdlib() {
  dynamic.decode3(
    User,
    dynamic.field("name", dynamic.string),
    dynamic.field("height", dynamic.float),
    dynamic.field("age", dynamic.int),
  )
}

The standard library requires two thing from the developer. You have to use the right function, decode3 in this case, because the user has 3 fields and you have to list the fields in the same order as the record definition.

The first point is generally not a problem until you reach 10 fields, because there is no decode10 function. The second point is a bigger problem. When developing, it is very common to modify existing records (add/remove fields). When you do this, it is very easy to mess up the order int the decoder. And if the two fields you swapped are both strings, the compiler will not catch this mistake. You will end up with bad data in your application.

The decode package decode

pub fn user_decoder_decode() {
  decode.into({
    use name <- decode.parameter
    use height <- decode.parameter
    use age <- decode.parameter
    User(name, height, age)
  })
  |> decode.field("name", decode.string)
  |> decode.field("height", decode.float)
  |> decode.field("age", decode.int)
}

The decode package is a bit better than the standard library. Now you can decode records with any number of fields in the same way easily. However the second problem still persists. The second decode.field("height", decode.float) call has to match up with the second use height <- decode.parameter call, which needs to match up with the right field in the record definition. If you mess this up during refactoring or developing a new feature, you will end up with bad data in your application.

Toy

pub fn user_decoder_toy() {
  use name <- toy.field("name", toy.string)
  use height <- toy.field("height", toy.float)
  use age <- toy.field("age", toy.int)

  toy.decoded(User(name:, height:, age:))
}

Toy solves both problems. Each field is defined on a single line. And since gleam 1.4.0, we can construct the record with the label shorthand syntax. By using this syntax the compiler guarantees that the name value will be assigned to the name field in the record, regardless of the order you specify them in. This makes is much harder to mess up the decoder when refactoring.

But there is a catch. Every line of the decoder will be executed on every invocation. To return errors for all fields at once, we need to continue decoding even after the first error. But what will be in the name variable if the name field failed to decode? The answer is a default string value (empty string in this case). So you have to make sure to not perform any side effects in the decoder, because withing the decoder function you are never guaranteed that the data is actually valid.

pub fn user_decoder_toy() {
  use name <- toy.field("name", toy.string)
  // This is bad, you should never perform side effects in the decoder
  io.println(name)
  use height <- toy.field("height", toy.float)
  use age <- toy.field("age", toy.int)

  toy.decoded(User(name:, height:, age:))
}

This is not as scary as it seems. When you are using toy and decode a value with it, you will always get valid data. The rule only applies withing the decoder itself.

And in practice, the rule don't perform side effects in the decoder is much easier to follow than the rule make sure the field order in the decoder always matches the record definition.

Usage

Here is a complex example of a decoder, including validation. This should give you a good overview of the capabilities of toy.

import birl
import gleam/dynamic
import gleam/io
import gleam/json
import gleam/option.{type Option}
import gleam/order
import gleam/result
import toy

pub type Guest {
  Guest(name: String, age: Int, special_needs: Option(String))
}

pub type Table {
  Any
  Specific(table_number: Int)
}

pub type Reservation {
  Reservation(
    person_name: String,
    email: String,
    datetime: birl.Time,
    guests: List(Guest),
    table: Table,
    notes: Option(String),
  )
}

pub fn time_decoder() {
  toy.string
  |> toy.try_map(birl.now(), fn(val) {
    birl.parse(val)
    |> result.replace_error([toy.ToyError(toy.InvalidType("DateTime", val), [])])
  })
}

pub fn time_future(val: birl.Time) {
  case birl.compare(val, birl.now()) {
    order.Gt -> Ok(Nil)
    _ ->
      Error([
        toy.ToyError(
          toy.ValidationFailed("date_future", "DateTime", birl.to_http(val)),
          [],
        ),
      ])
  }
}

pub fn reservation_decoder() {
  use person_name <- toy.field("person_name", toy.string |> toy.string_nonempty)
  use email <- toy.field("email", toy.string |> toy.string_email)
  use datetime <- toy.field(
    "datetime",
    time_decoder() |> toy.refine(time_future),
  )
  use guests <- toy.field(
    "guests",
    toy.list({
      use name <- toy.field("name", toy.string)
      use age <- toy.field("age", toy.int |> toy.int_min(18))
      use special_needs <- toy.optional_field("special_needs", toy.string)
      toy.decoded(Guest(name:, age:, special_needs:))
    })
      |> toy.list_nonempty,
  )
  use table <- toy.field("table", {
    use type_ <- toy.field("type", toy.string)

    case type_ {
      "specific" -> {
        use table_number <- toy.field("table_number", toy.int)
        toy.decoded(Specific(table_number:))
      }
      "any" -> toy.decoded(Any)
      _ -> toy.fail(toy.InvalidType("Table", type_), Any)
    }
  })
  use notes <- toy.field("notes", toy.string |> toy.nullable)
  toy.decoded(Reservation(
    person_name:,
    email:,
    datetime:,
    guests:,
    table:,
    notes:,
  ))
}

const data = "
{
  \"person_name\": \"Alice\",
  \"email\": \"alice@example.com\",
  \"datetime\": \"2025-01-01T12:00:00Z\",
  \"guests\": [
    { \"name\": \"Alice\", \"age\": 25 },
    { \"name\": \"Bob\", \"age\": 30, \"special_needs\": \"Vegan\" }
  ],
  \"table\": { \"type\": \"specific\", \"table_number\": 1 },
  \"notes\": null
}
"

pub fn main() {
  let assert Ok(data) = json.decode(data, dynamic.dynamic)

  data |> toy.decode(reservation_decoder()) |> io.debug
}

Further documentation can be found at https://hexdocs.pm/toy.