shape
: a decidable static shape system for JSON
This library implements a Rust-based type system powerful enough to represent any kind of JSON data, offering type-theoretic operations like simplification, satisfaction testing, child shape selection, union and intersection shapes, delayed shape binding, flexible error handling, and more.
[!CAUTION] This library is still in the early stages of development, and its API will not be fully stable until the 1.0.0 release.
Installation
This crate provides a library, so installation means adding it as a dependency
to your Cargo.toml
file:
Documentation
See the cargo doc
-generated documentation
for detailed information about the Shape
struct and ShapeCase
enum.
The Shape
struct
pub type Ref<T> = Arc;
To support flexible recombinations of shapes and their subshapes, the top-level
Shape
struct wraps a reference counted ShapeCase
enum variant. Reference
counting not only simplifies sharing subtrees among different Shape
structures, but also prevents rustc
from complaining about the Shape
struct
referring to itself without indirection.
Since this ShapeCase
value is immutable, we can precompute and cache its hash
in the case_hash
field, then manually implement the std::hash::Hash
trait to
ignore the case
field in favor of this cached case_hash
field:
This allows Shape
hashing to reuse previously computed subtree hashes, rather
than rehashing the entire tree each time a hash is requested.
Obtaining a Shape
Instead of tracking whether a given ShapeCase
has been simplified or not, we
can simply mandate that Shape
always wraps simplified shapes.
This invariant is enforced by restricting how Shape
instances can be
(publicly) created: all Shape
instances must come either from calling
ShapeCase::simplify
, or from calling the crate-internal static method
Shape::new_from_simplified
.
lazy_static!
Notice that ShapeCase::simplify
takes ownership of its input self
value. If
this is not the behavior you want, you can always clone your ShapeCase
value
before simplifying it, thereby simplifying only the clone. However, you cannot
use an unsimplified ShapeCase
as a child of another ShapeCase
value, since
child shapes are always wrapped with Shape
.
Testing supershape-subshape acceptance
To test whether the set of all values accepted by one Shape
is a subset of the
set of all values accepted by another Shape
, use the
supershape.accepts(&subshape) -> bool
method, or its inverse
subshape.satisfies(&supershape) -> bool
.
For example, a Shape::one
union shape accepts any member shape of the union,
let int_string_union = one;
assert!;
assert!;
assert!;
assert!;
assert_eq!;
assert_eq!;
assert_eq!;
Error satisfaction
A ShapeCase::Error
variant generally represents a failure of shape processing,
but it can also optionally report Some(partial)
shape information in cases
when there is a likely best guess at what the shape should be.
For this reason, a ShapeCase::Error
shape either satisfies/accepts itself
trivially (according to ==
equality), or it can define a partial
shape to
satisfy shapes that accept that partial
shape.
This partial: Option<Shape>
field allows errors to provide guidance
(potentially with chains of multiple errors) without interfering with the
accepts/satisfies logic.
let error = error_with_partial;
assert!;
assert_eq!;
assert!;
assert_eq!;
The null
singleton and the None
shape
ShapeCase::Null
represents the singleton null
value found in JSON. It
satisfies and accepts only itself and no other shapes, except unions that allow
null
as a member, or errors that wrap null
as a partial shape.
ShapeCase::None
represents the absence of a value, and is often used to
represent optional values. In contrast to null
, None
is satisfied by
(accepts) any shape, but does not satisfy (is not accepted by) any shape (except
unions that include None
as a member). This makes it like TypeScript's
unknown
value, satisfied by all but satisfying none.
When either null
or None
participate in a Shape::one
union shape, they are
usually preserved (other than being deduplicated) because they represent
distinct possibilities.
However, when null
participates in a ShapeCase::All
intersection shape, it
"poisons" the intersection and causes the whole thing to simplify to null
.
This allows a single intersection member shape to override the whole
intersection, which is useful for reporting certain kinds of error conditions
(especially in GraphQL).
By contrast, None
does not poison intersections, but is simply ignored. This
makes sense if you think of Shape::all
intersections as merging their member
shapes: when you merge None
with another shape, you get the other shape back,
because None
imposes no additional expectations.