schematics v0.5.0
Schematics
A modern data validation library for Crystal with rich error reporting and type safety, inspired by Python's Pydantic.
Features
- Pydantic-Style Models: Declarative model definitions with automatic validation
- Type Safe: Leverages Crystal's compile-time type system
- Rich Validators: Built-in validators for common use cases (length, format, ranges, etc.)
- Custom Validators: Fluent API for adding constraints and rules
- JSON Serialization: Automatic
to_jsonandfrom_jsonmethods - High Performance: ~2μs per validation with zero runtime overhead
- Composable: Build complex schemas from simple validators
Installation
-
Add the dependency to your
shard.yml:dependencies: schematics: github: qequ/schematics -
Run
shards install
Quick Start
Model DSL (Recommended)
Define Pydantic-style models with declarative field definitions:
require "schematics"
class User < Schematics::Model
field email, String,
required: true,
validators: [
Schematics.min_length(5),
Schematics.format(/@/),
]
field username, String,
required: true,
validators: [Schematics.min_length(3)]
field age, Int32?,
validators: [
Schematics.gte(0),
Schematics.lte(120),
]
field role, String,
default: "user",
validators: [Schematics.one_of(["admin", "user", "guest"])]
end
# Create and validate
user = User.new(
email: "john@example.com",
username: "john_doe",
age: 25,
role: "user"
)
user.valid? # => true
user.errors # => {}
# JSON serialization
json = user.to_json
# => {"email":"john@example.com","username":"john_doe","age":25,"role":"user"}
# JSON deserialization
user = User.from_json(json)
For simpler use cases without models, see the Schema-Based Validation section below.
Model DSL
The Model DSL provides a Pydantic-style declarative approach to defining data models with automatic validation, JSON serialization, and type safety.
Defining Models
class Product < Schematics::Model
field name, String,
required: true,
validators: [
Schematics.min_length(1),
Schematics.max_length(100),
]
field price, Float64,
required: true,
validators: [Schematics.gt(0)]
field quantity, Int32,
default: 0,
validators: [Schematics.gte(0)]
field category, String,
validators: [Schematics.one_of(["electronics", "books", "clothing"])]
field tags, Array(String)?,
default: nil
end
Built-in Validators
String Validators
# Length constraints
Schematics.min_length(5) # Minimum 5 characters
Schematics.max_length(100) # Maximum 100 characters
# Pattern matching
Schematics.format(/@/) # Must contain '@'
Schematics.matches(/^[a-z]+$/) # Only lowercase letters
# Value constraints
Schematics.one_of(["admin", "user", "guest"]) # Must be one of these values
Numeric Validators
# Comparison operators
Schematics.gte(0) # Greater than or equal to 0
Schematics.lte(120) # Less than or equal to 120
Schematics.gt(0) # Greater than 0
Schematics.lt(100) # Less than 100
# Range constraint
Schematics.range(1, 5) # Between 1 and 5 (inclusive)
Field Options
class User < Schematics::Model
# Required field (must be provided at initialization)
field email, String, required: true
# Optional/nilable field
field phone, String?
# Field with default value
field role, String, default: "user"
# Field with validators
field age, Int32,
validators: [Schematics.gte(0), Schematics.lte(120)]
# Field with type coercion (converts strings to target type from JSON)
field max_connections, Int32, coerce: true
# Combining options
field username, String,
required: true,
validators: [
Schematics.min_length(3),
Schematics.max_length(20),
Schematics.matches(/^[a-zA-Z0-9_]+$/),
]
end
Custom Validation
Override the validate_model method for custom validation logic:
class Account < Schematics::Model
field email, String, required: true
field age, Int32?
field account_type, String
def validate_model
# Custom cross-field validation
if age_val = age
if age_val < 18 && account_type == "premium"
add_error(:account_type, "Premium accounts require age 18+")
end
end
# Custom email domain validation
if email.ends_with?(".gov") && account_type != "government"
add_error(:email, "Government emails require government account type")
end
end
end
Validation Methods
user = User.new(email: "test@example.com", username: "john")
# Check if valid (returns Bool)
user.valid? # => true/false
# Get validation errors
user.errors # => Hash(Symbol, Array(String))
# Example: {:email => ["must contain @"], :age => ["must be >= 0"]}
# Validate and raise on error
user.validate! # Raises Schematics::ValidationError if invalid
JSON Serialization
Models automatically get to_json and from_json methods:
class Article < Schematics::Model
field title, String
field published, Bool
field views, Int32
end
# To JSON
article = Article.new(title: "Hello World", published: true, views: 100)
json = article.to_json
# => {"title":"Hello World","published":true,"views":100}
# From JSON
article = Article.from_json(json)
article.title # => "Hello World"
article.published # => true
article.views # => 100
Type Safety
Models provide compile-time type checking:
class Post < Schematics::Model
field title, String
field likes, Int32
field active, Bool
end
post = Post.new(title: "Hi", likes: 10, active: true)
# These are type-safe at compile time
title : String = post.title # ✓ OK
likes : Int32 = post.likes # ✓ OK
active : Bool = post.active # ✓ OK
# Property modification
post.title = "New Title"
post.likes = 20
Working with Nilable Fields
class Profile < Schematics::Model
field name, String, required: true
field bio, String? # Optional, defaults to nil
field age, Int32? # Optional, defaults to nil
field avatar, String? # Optional, defaults to nil
end
profile = Profile.new(name: "Alice", bio: nil, age: 25, avatar: nil)
# Safe access to nilable fields
if bio = profile.bio
puts "Bio: #{bio}"
else
puts "No bio"
end
# Or use try
profile.bio.try { |b| puts "Bio: #{b}" }
Complete Example
class BlogPost < Schematics::Model
field title, String,
required: true,
validators: [
Schematics.min_length(5),
Schematics.max_length(200),
]
field content, String,
required: true,
validators: [Schematics.min_length(10)]
field author, String,
required: true
field tags, Array(String)?,
default: nil
field status, String,
default: "draft",
validators: [Schematics.one_of(["draft", "published", "archived"])]
field views, Int32,
default: 0,
validators: [Schematics.gte(0)]
field published_at, String?
def validate_model
# Custom validation: published posts must have published_at
if status == "published" && published_at.nil?
add_error(:published_at, "Published posts must have a published date")
end
end
end
# Create a blog post
post = BlogPost.new(
title: "Getting Started with Crystal",
content: "Crystal is a statically typed language...",
author: "Alice",
tags: nil,
status: "draft",
views: 0,
published_at: nil
)
if post.valid?
puts "Post is valid!"
puts post.to_json
else
puts "Validation errors:"
post.errors.each do |field, messages|
puts " #{field}: #{messages.join(", ")}"
end
end
Performance
The Model DSL uses compile-time macros for zero runtime overhead:
# Validation performance
10_000.times do
user = User.new(email: "test@example.com", username: "test", age: 25, role: "user")
user.valid?
end
Struct Support
For immutable value types, use Schematics::Struct instead of inheriting from Model:
struct Point
include Schematics::Struct
field x, Float64, validators: [Schematics.gte(0.0)]
field y, Float64, validators: [Schematics.gte(0.0)]
end
struct ServerConfig
include Schematics::Struct
field host, String, required: true
field port, Int32, default: 8080, validators: [Schematics.range(1, 65535)]
field debug, Bool, default: false
end
# Usage is the same as Model
point = Point.new(x: 10.0, y: 20.0)
point.valid? # => true
point.x # => 10.0 (read-only)
config = ServerConfig.new(host: "localhost", port: 3000, debug: true)
config.to_json # => {"host":"localhost","port":3000,"debug":true}
Custom Validation for Structs
Override _collect_custom_errors for custom validation logic:
struct Rectangle
include Schematics::Struct
field width, Float64, validators: [Schematics.gt(0.0)]
field height, Float64, validators: [Schematics.gt(0.0)]
protected def _collect_custom_errors(errs : Hash(Symbol, Array(String)))
if width > height * 10
errs[:width] ||= [] of String
errs[:width] << "aspect ratio too extreme"
end
end
end
Type Coercion
Schematics supports automatic type coercion when deserializing from JSON. Enable it per-field with coerce: true. By default, strict mode is used (no automatic coercion).
Basic Usage
class UserSettings < Schematics::Model
field username, String, required: true
field max_connections, Int32, coerce: true # "50" -> 50
field timeout, Float64, coerce: true # 60 -> 60.0
field debug_mode, Bool, coerce: true # "true" -> true
end
# JSON with string values gets coerced to proper types
json = %({"username": "john", "max_connections": "50", "timeout": 30, "debug_mode": "yes"})
settings = UserSettings.from_json(json)
settings.max_connections # => 50 (Int32)
settings.timeout # => 30.0 (Float64)
settings.debug_mode # => true (Bool)
Supported Coercions
| Target Type | Accepted Source Types |
|---|---|
Int32 |
Int32, Int64 (in range), Float64 (whole numbers), String (numeric), Bool (0/1) |
Int64 |
Int32, Int64, Float64 (whole numbers), String (numeric), Bool (0/1) |
Float64 |
Float64, Int32, Int64, String (numeric) |
Bool |
Bool, Int (0/1), String (true/false/yes/no/on/off/1/0) |
String |
String, Int32, Int64, Float64, Bool |
Boolean Coercion Values
The following string values are recognized for boolean coercion:
- True:
"true","yes","on","1","t","y"(case-insensitive) - False:
"false","no","off","0","f","n"(case-insensitive)
Strict Mode (Default)
Without coerce: true, fields require exact type matches:
class StrictConfig < Schematics::Model
field name, String
field port, Int32 # No coercion
end
# This raises an error - port must be an actual integer
StrictConfig.from_json(%({"name": "app", "port": "8080"})) # Error!
# This works - port is an integer
StrictConfig.from_json(%({"name": "app", "port": 8080})) # OK
Coercion with Structs
Type coercion also works with Schematics::Struct:
struct Coordinate
include Schematics::Struct
field latitude, Float64, coerce: true
field longitude, Float64, coerce: true
end
coord = Coordinate.from_json(%({"latitude": "37.7749", "longitude": "-122.4194"}))
coord.latitude # => 37.7749 (Float64)
coord.longitude # => -122.4194 (Float64)
Error Handling
When coercion fails, a descriptive error is raised:
class Config < Schematics::Model
field port, Int32, coerce: true
end
Config.from_json(%({"port": "not a number"}))
# Raises: "Failed to coerce String to Int32 for field 'port'"
For nilable fields, failed coercion returns nil instead of raising:
class OptionalConfig < Schematics::Model
field port, Int32?, coerce: true
end
config = OptionalConfig.from_json(%({"port": "not a number"}))
config.port # => nil
Schema-Based Validation
For simpler use cases without models, use the schema API directly:
Schema Types
# String validation
schema = Schematics::Schema(String).new
schema.valid?("hello") # => true
schema.valid?(123) # => false
# Integer validation
schema = Schematics::Schema(Int32).new
schema.valid?(42) # => true
# Float validation
schema = Schematics::Schema(Float64).new
schema.valid?(3.14) # => true
Arrays
# Homogeneous arrays
schema = Schematics::Schema(Array(Int32)).new
schema.valid?([1, 2, 3]) # => true
schema.valid?([1, "two", 3]) # => false
# Nested arrays
schema = Schematics::Schema(Array(Array(String))).new
schema.valid?([["a", "b"], ["c", "d"]]) # => true
Hashes
# Simple hashes
schema = Schematics::Schema(Hash(String, Int32)).new
schema.valid?({"a" => 1, "b" => 2}) # => true
# Nested hashes
schema = Schematics::Schema(Hash(String, Hash(String, Int32))).new
schema.valid?({"a" => {"x" => 1}, "b" => {"y" => 2}}) # => true
# Hashes with arrays
schema = Schematics::Schema(Hash(String, Array(Int32))).new
schema.valid?({"numbers" => [1, 2, 3]}) # => true
Custom Validators
Build schemas with constraints using the fluent SchemaBuilder API:
# String with length constraints
email_schema = Schematics::SchemaBuilder(String).new
.min_length(5)
.max_length(100)
.add_validator("must contain @") { |s| s.includes?("@") }
.build
# Number with range constraints
age_schema = Schematics::SchemaBuilder(Int32).new
.min_value(18)
.max_value(120)
.build
# Array with size constraints
tags_schema = Schematics::SchemaBuilder(Array(String)).new
.min_size(1)
.max_size(10)
.build
Rich Error Reporting
Get detailed information about validation failures:
schema = Schematics::Schema(Array(Int32)).new
result = schema.validate([1, 2, "three", 4, "five"])
unless result.valid?
puts "Validation failed:"
result.errors.each do |error|
puts " Path: #{error.path}"
puts " Message: #{error.error_message}"
puts " Value: #{error.value}"
end
end
# Output:
# Path: root[2]
# Message: expected type Int32, got String
# Value: three
Parse with Type Safety
The parse method returns typed values or raises on error:
schema = Schematics::Schema(Int32).new
value = schema.parse(42) # Returns Int32
puts typeof(value) # => Int32
# Raises ValidationError on invalid data
begin
schema.parse("not a number")
rescue ex : Schematics::ValidationError
puts ex.message # => root: expected type Int32, got String
end
Real-World Examples
API Request Validation
def validate_create_user(data)
name_schema = Schematics::SchemaBuilder(String).new
.min_length(2)
.max_length(50)
.build
email_schema = Schematics::SchemaBuilder(String).new
.min_length(5)
.add_validator("valid email") { |s| s.includes?("@") }
.build
age_schema = Schematics::SchemaBuilder(Int32).new
.min_value(18)
.build
errors = {} of String => String
unless name_schema.valid?(data["name"]?)
errors["name"] = "Invalid name"
end
unless email_schema.valid?(data["email"]?)
errors["email"] = "Invalid email"
end
unless age_schema.valid?(data["age"]?)
errors["age"] = "Must be 18 or older"
end
{valid: errors.empty?, errors: errors}
end
Reusable Schemas
module Schemas
EMAIL = Schematics::SchemaBuilder(String).new
.min_length(5)
.add_validator("must be valid email") { |s| s.includes?("@") }
.build
POSITIVE_INT = Schematics::SchemaBuilder(Int32).new
.min_value(1)
.build
USER_TAGS = Schematics::SchemaBuilder(Array(String)).new
.min_size(1)
.max_size(10)
.build
end
# Use throughout your application
Schemas::EMAIL.validate("user@example.com")
Schemas::POSITIVE_INT.validate(42)
Roadmap
- Basic type validation
- Array and Hash validation
- Custom validators
- Rich error reporting
- Min/max constraints
- Model DSL (Pydantic-style classes)
- JSON serialization/deserialization
- Built-in validators (length, format, ranges, one_of)
- Custom validation methods
- Struct support (immutable value types)
- Type coercion
- JSON Schema export
- Async validation
Contributing
- Fork it (https://github.com/qequ/schematics/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Alvaro Frias Garay - creator and maintainer
License
MIT License - see LICENSE file for details
schematics
- 5
- 0
- 0
- 0
- 1
- 12 days ago
- February 5, 2023
MIT License
Fri, 23 Jan 2026 09:27:09 GMT