# pedantic

<p align="center">
  <img src="https://raw.githubusercontent.com/gleam-foundry/pedantic/main/assets/images/pedantic-logo.png" alt="Pedantic logo" width="480">
</p>

[![Package Version](https://img.shields.io/hexpm/v/pedantic)](https://hex.pm/packages/pedantic)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/pedantic/)

Explicit codecs. Trusted data.

Error-accumulating, bidirectional validation and sanitization codecs for Gleam.
Pedantic transforms untrusted external payloads into typed domain values while
supporting Erlang and JavaScript targets.

## Contents

- [Start here](#start-here)
- [Why Pedantic](#why-pedantic)
- [Sanitization and normalization](#sanitization-and-normalization)
- [Build schemas](#build-schemas)
- [Installation](#installation)
- [Development and tests](#development-and-tests)
- [API reference](#api-reference)
- [License](#license)

## Start here

Parse a dynamic value with either coercing or exact primitive codecs:

```gleam
import pedantic

pub fn parse_values() {
  // Coerced primitives accept compatible representations.
  pedantic.safe_parse(pedantic.coerced_int, to_dynamic("123"))
  // -> Ok(123)

  pedantic.safe_parse(pedantic.coerced_bool, to_dynamic("true"))
  // -> Ok(True)

  // Exact primitives reject type mismatches.
  pedantic.safe_parse(pedantic.exact_int, to_dynamic("123"))
  // -> Error(Report(...))
}
```

For JSON request payloads, use the JSON entrypoint:

```gleam
case pedantic.safe_parse_json(user_codec(), json_payload) {
  Ok(user) -> register_user(user)
  Error(report) -> return_validation_errors(report)
}
```

## Why Pedantic

Pedantic is built around three ideas:

- **Accumulate, don't short-circuit**: collect nested failures in one pass with
  exact paths such as `["address", "city"]`.
- **Choose coercion explicitly**: accept query-string representations when
  useful, or enforce strict JSON types at the boundary.
- **Keep schemas bidirectional**: decode untrusted input into domain values and
  encode those values back to JSON through the same codec.

## Sanitization and normalization

Sanitizers transform successfully decoded values before later validators run.
They make equivalent input consistent, but they are not replacements for
context-specific HTML, SQL, shell, or URL escaping.

```gleam
pub const username_codec =
  pedantic.string
  |> pedantic.collapse_whitespace()
  |> pedantic.trim()
  |> pedantic.lowercase()
  |> pedantic.remove_chars("-_ ")
  |> pedantic.min_length(3)

pub const optional_nickname_codec =
  pedantic.string
  |> pedantic.empty_as_none()

pub const normalized_email_codec =
  pedantic.string
  |> pedantic.normalize_email()
  |> pedantic.email("Please provide a valid email address")
```

Available string sanitizers include `trim`, `collapse_whitespace`, `lowercase`,
`uppercase`, `replace`, `remove_chars`, `allow_chars`, `strip_control_chars`,
`default_if_blank`, `empty_as_none`, `normalize_email`, and `normalize_uuid`.
Additional helpers include `digits_only`, `letters_only`, `remove_whitespace`,
`normalize_line_endings`, and `strip_non_ascii`.
`replace` performs literal substring replacement rather than regular-expression
replacement. Transformations run while decoding; encoding uses the application
value.

## Build schemas

Compose constraints, sanitizers, nested objects, defaults, and custom error
messages into a bidirectional codec:

```gleam
import pedantic
import pedantic/curry

pub type Address {
  Address(city: String, zip: String)
}

pub type User {
  User(id: Int, name: String, address: Address, role: String)
}

fn address_codec() {
  pedantic.object("Address", curry.curry2(Address))
  |> pedantic.key_required(
    "city",
    pedantic.string |> pedantic.trim(),
    fn(address: Address) { address.city },
  )
  |> pedantic.key_required(
    "zip",
    pedantic.string |> pedantic.length(5),
    fn(address: Address) { address.zip },
  )
  |> pedantic.build()
}

pub fn user_codec() {
  pedantic.object("User", curry.curry4(User))
  |> pedantic.key_required(
    "id",
    pedantic.int |> pedantic.positive(),
    fn(user: User) { user.id },
  )
  |> pedantic.key_required("name", pedantic.string, fn(user: User) { user.name })
  |> pedantic.key_required(
    "address",
    address_codec(),
    fn(user: User) { user.address },
  )
  |> pedantic.key_with_default(
    "role",
    pedantic.string,
    "guest",
    fn(user: User) { user.role },
  )
  |> pedantic.build()
}
```

Nested failures can be flattened for an API response:

```gleam
let issue_map = pedantic.issues_dict(report)
// {
//   "id": ["Value must be at least 1"],
//   "address.zip": ["Length must be exactly 5"]
// }
```

## Installation

Add Pedantic to a Gleam project:

```sh
gleam add pedantic
```

Then import the top-level API:

```gleam
import pedantic
```

## Development and tests

The library is tested on both Erlang and JavaScript runtimes:

```sh
gleam format --check
gleam build
gleam test
gleam test --target js
gleam docs build
```

## API reference

### Primitive codecs

`int`, `float`, `bool`, and `string` use coercing decoders. Use
`exact_int`, `exact_float`, `exact_bool`, and `exact_string` when coercion is
not wanted.

### Validation

Use `min`, `max`, `min_length`, `max_length`, `length`, `positive`, `negative`,
`non_negative`, `non_positive`, `email`, `uuid`, `check`, `assert_that`, and
`refine` to constrain decoded values.

### Composition

Use `list`, `optional`, `dict`, `object`, `key_required`, `key_optional`,
`key_with_default`, `build`, and `custom` to compose schemas. Use `encode_json`
to serialize a trusted value through its codec.

## License

MIT License - Copyright (c) 2026 Antonio Ognio

Made with ❤️  from 🇵🇪. El Perú es clave 🔑.
