facet v0.1.0
Facet
Facet is a standalone validation library for Crystal, part of the Quartz ecosystem. Rules are declared next to the field they validate, validation runs on immutable objects, and the result is a plain value — no exceptions. Facet is 100% standalone: it does not know Quartz or any other framework, and no framework needs it.
The shardbox validation category lists eight shards; only two are active. The rest — accord, validations, assert, validator, denetmen — have been dormant for five to nine years or are archived, and Athena::Validator, genuinely good, belongs to the Athena ecosystem. Crystal had no framework-independent validation shard with broad adoption. That is the space Facet occupies.
Facet comes first because Quartz converts types but does not validate values. age : Int32 guarantees an integer, not a positive one; email : String does not guarantee an email. Anyone adopting Quartz hits this within the first hour, and the Quartz spec deliberately deferred validation here.
What Facet is not
Facet is not ActiveRecord-style validation of mutable entities with dirty tracking, not collection validation, and not i18n. It validates immutable structs and records, and nothing else. Out of scope for v1:
| Outside | Status |
|---|---|
| Automatic integration with Quartz | Rejected: the controller calls Facet.validate explicitly; the guarantee "a controller never receives invalid data" is discipline, the accepted cost of independence |
| Partial / dirty-tracking validation of mutable entities | An Obsidian concern, post-v1 |
Nested collections (Array(T), Hash(String, T)) |
Post-v1 |
| Custom rules via your own annotation | Post-v1 |
| i18n | Custom message: covers the case |
| Programmatic rule composition without annotations | Post-v1, if asked |
Installation
Add the dependency to your shard.yml:
dependencies:
facet:
github: QuartzForge/facet
version: ~> 0.1.0
Then:
shards install
and require "facet" in your entry point.
A complete working example
This is examples/hello/ in this repository — it compiles and runs as-is. The only difference from the file on disk is the require line: your app requires the installed shard ("facet"), the in-repo example requires the sources relatively.
require "facet"
record Signup, name : String, email : String, age : Int32 do
include Facet::Validatable
@[Facet::Assert::NotBlank]
def name : String
@name
end
@[Facet::Assert::Email]
def email : String
@email
end
@[Facet::Assert::Min(18, message: "you must be an adult")]
def age : Int32
@age
end
end
result = Facet.validate(Signup.new(name: "", email: "x", age: 10))
if result.valid?
puts "signup ok"
else
result.errors.each { |error| puts "#{error.field}: #{error.message}" }
end
Build it and run it:
crystal build examples/hello/src/app.cr -o /tmp/facet-hello
/tmp/facet-hello
What you get back:
name: must not be blank
email: must be a valid email
age: you must be an adult
Three things are happening here. include Facet::Validatable makes the type validatable; each annotated def in the record block declares one rule next to the field it validates. Facet.validate is the single entry point — a type that does not include Validatable does not compile when validated. Errors come back in declaration order, each with the field name and the message.
Rules
| Annotation | Target type | Semantics | Default message |
|---|---|---|---|
NotBlank |
String |
fails on "" and " " (trimmed); passes on nil |
must not be blank |
Required |
T? |
fails if nil |
is required |
Length(min:, max:) |
String |
both bounds optional; min > max is a compile error |
length must be between X and Y (or at least X / at most X) |
Min(n) |
Int32, Int64, Float64 |
inclusive | must be at least n |
Max(n) |
same | inclusive | must be at most n |
GreaterThan(n) |
same | exclusive | must be greater than n |
LessThan(n) |
same | exclusive | must be less than n |
Positive |
same | > 0 — sugar over GreaterThan(0) |
must be positive |
Email |
String |
pragmatic regex, not RFC 5322 (the RFC is a trap: its grammar accepts strings no mail system would route); target a@b.c |
must be a valid email |
Format(regex) |
String |
full match of the regex | must match /.../ |
OneOf(values...) |
any | value belongs to the set | must be one of: a, b, c |
Default messages are a contract pinned by specs — changing one is a visible diff.
Custom messages
Every rule annotation accepts message::
@[Facet::Assert::Min(18, message: "you must be an adult")]
def age : Int32
@age
end
The custom message replaces the default; everything else about the rule is unchanged.
The golden rule of nil
Nil is absence, not a value. Two rules decide what absence means, and they differ in their verdict on it:
Requiredfails when the value isnil.NotBlankfails on""and" "but passes onnil— absence is a decision of the type. "Required and non-empty" is the combinationRequired+NotBlank.
Every other rule skips a nil value: email : String? with @[Facet::Assert::Email] passes nil and fails "x". A nilable field is not invalid when empty; it is simply not validated.
Nested validation
When a property's type also includes Validatable, validation descends into it automatically and the error paths gain a prefix:
record Address, city : String, zip : String do
include Facet::Validatable
@[Facet::Assert::NotBlank]
def city : String
@city
end
end
record Order, address : Address do
include Facet::Validatable
end
result = Facet.validate(Order.new(address: Address.new(city: "", zip: "01000")))
result.errors.map(&.field) # => ["address.city"]
No configuration is needed: a composite payload validates on its own.
The contract with Quartz
Facet is standalone by design, and its error shape is drawn to match Quartz's. Facet::FieldError#field and Facet::FieldError#message line up with Quartz::FieldError, so a controller converts a failed validation into a 400 problem document in a few lines:
unless result.valid?
errors = result.errors.map { |e| Quartz::FieldError.new(e.field, "body", e.message) }
raise Quartz::BindError.new(errors)
end
The conversion lives in the application. Facet itself knows nothing about Quartz; a quartz-facet adapter is a post-v1 possibility if the community asks for one.
Configuration errors are compile errors
A malformed rule — @[Facet::Assert::Length(min: 5, max: 3)] — is a {% raise %} inside the macro: the build fails with a message naming the type, the field, and the problem. Wrong configuration never reaches production; it is a compile error, not a runtime ConfigError.
Known limitations
Facet is honest about its edges. All of the following are real; read them before you build on this version.
Positive is sugar over GreaterThan(0) — it is exactly that rule with the bound 0 and its own default message.
Positive on a nilable field is a compile error. The rule instantiates with the field's union type and the build fails. Make the field non-nilable, or validate with GreaterThan(1) (which skips nil like every non-presence rule).
OneOf takes its values as positional arguments — @[Facet::Assert::OneOf("a", "b")]. There is no named-argument form.
Length bounds are Int32. Length(min: 1.5) is a compile error.
A validatable-typed ivar with value annotations keeps only the nested rule. When a property's type is itself validatable, the macro emits the nested validation and silently ignores any value annotations on that property. Verified by probe; not pinned by a spec.
The macro does not check type × rule compatibility in v1. @[Facet::Assert::Min(18)] on a String field fails when the generic rule compiles, with the compiler's own error rather than a friendly message. A compatibility check is a post-v1 candidate.
Duplicate annotations of the same rule on one field are deduplicated, last one wins. Two @[Facet::Assert::Min] on the same field do not both run; the later declaration silently replaces the earlier.
Rules on the same field run in collector-branch order (NotBlank, Required, Length, Min, ...), not annotation declaration order. Field order is preserved; only the order of rules within a field is fixed.
record with an annotated def in the block is the documented pattern. An annotation on a getter also works — it lands on the ivar, and the collector reads both places.
Cyclic validatable types overflow the stack. A validatable type that (through a chain) contains itself recurses until the stack overflows. Out of scope for v1; the cycle detection in Quartz's compile-time container is the model for a fix.
Development
./scripts/setup
This runs shards install and installs the Crystalline language server. Crystalline is not a shard — it is a system binary installed by the script, which is why it does not appear in shard.yml.
See CONTRIBUTING.md for the workflow and how the compile-failure fixtures are treated.
License
MIT.
facet
- 0
- 0
- 0
- 0
- 1
- about 6 hours ago
- August 19, 2026
MIT License
Wed, 19 Aug 2026 06:35:50 GMT