When working with schemas, you have a choice beyond the Schema.Struct constructor.
You can leverage the power of classes through the Schema.Class utility, which comes with its own set of advantages tailored to common use cases:
Classes offer several features that simplify the schema creation process:
All-in-One Definition: With classes, you can define both a schema and an opaque type simultaneously.
Shared Functionality: You can incorporate shared functionality using class methods or getters.
Value Hashing and Equality: Utilize the built-in capability for checking value equality and applying hashing (thanks to Class implementing Data.Class).
Definition
To define a Class you need to provide:
The type of the class being created.
A unique identifier for the class.
The desired fields.
Example
In this setup, Person is a class where id is a number and name is a non-empty string.
The constructor for the class creates instances with these specified properties.
Classes Without Arguments
If your schema does not require any fields, you can define a class with an empty object:
Class Constructor as a Validator
When you define a class using Schema.Class, the constructor automatically checks that the provided properties adhere to the schema’s rules. Here’s how you can define and instantiate a Person class:
This ensures that each property of the Person instance, like id and name, meets the conditions specified in the schema, such as id being a number and name being a non-empty string.
If an instance is created with invalid properties, the constructor throws an error detailing what went wrong:
This error message clearly states that the name field failed the non-empty string predicate, providing precise feedback on why the validation failed.
There are scenarios where you might want to bypass validation during instantiation. Although not typically recommended, effect/Schema allows for this flexibility:
Hashing and Equality
Thanks to the implementation of Data.Class, instances of your classes automatically support the Equal trait, which allows for easy comparison:
However, be aware that the Equal trait checks for equality only at the first level. If, for instance, a field is an array, the returned instances will not be considered equal:
To ensure deep equality for arrays, use Schema.Data combined with Data.array:
Custom Getters and Methods
You have the flexibility to enhance schema classes with custom getters and methods.
Example
Using Classes as Schemas
When you define a class using Schema.Class, it not only creates a new class but also treats this class as a schema.
This means the class can be utilized wherever a schema is expected.
The fields Property
The class also includes a fields static property, which outlines the fields defined during the class creation.
Annotations and Transformations
A class that extends Schema.Class implicitly forms a schema transformation from a structured type to a class type.
For instance, consider the following definition:
This class definition serves as a transformation from the following struct schema:
to a schema that represents the Person class.
Adding Annotations
There are two primary ways to add annotations depending on your requirements:
Adding Annotations to the Struct Schema (the “from” part of the transformation):
You can annotate the struct schema component, which is transformed into the class.
Adding Annotations to the Class Schema (the “to” part of the transformation):
Alternatively, annotations can be added directly to the class schema, affecting how the class is represented as a schema.
Recursive Schemas
The Schema.suspend combinator is useful when you need to define a schema that depends on itself, like in the case of recursive data structures.
In this example, the Category schema depends on itself because it has a field subcategories that is an array of Category objects.
Mutually Recursive Schemas
Here’s an example of two mutually recursive schemas, Expression and Operation, that represent a simple arithmetic expression tree.
Recursive Types with Different Encoded and Type
Defining a recursive schema where the Encoded type differs from the Type type adds another layer of complexity.
In such cases, we need to define an interface for the Encoded type.
Let’s consider an example: suppose we want to add an id field to the Category schema, where the schema for id is NumberFromString.
It’s important to note that NumberFromString is a schema that transforms a string into a number, so the Type and Encoded types of NumberFromString differ, being number and string respectively.
When we add this field to the Category schema, TypeScript raises an error:
This error occurs because the explicit annotation S.suspend((): S.Schema<Category> => Category is no longer sufficient and needs to be adjusted by explicitly adding the Encoded type:
As we’ve observed, it’s necessary to define an interface for the Encoded of the schema to enable recursive schema definition, which can complicate things and be quite tedious.
One pattern to mitigate this is to separate the field responsible for recursion from all other fields.
Tagged Class variants
You can also create classes that extend TaggedClass and TaggedError from the effect/Data module.
Example
Extending existing Classes
In situations where you need to augment your existing class with more fields, the built-in extend static utility comes in handy.
Example
Transformations
You have the option to enhance a class with (effectful) transformations.
This becomes valuable when you want to enrich or validate an entity sourced from a data store.
The decision of which API to use, either transformOrFail or transformOrFailFrom, depends on when you wish to execute the transformation:
Using transformOrFail:
The transformation occurs at the end of the process.
It expects you to provide a value of type { age: Option<number> }.
After processing the initial input, the new transformation comes into play, and you need to ensure the final output adheres to the specified structure.
Using transformOrFailFrom:
The new transformation starts as soon as the initial input is handled.
You should provide a value { age?: number }.
Based on this fresh input, the subsequent transformation { age: Schema.optionalToOption(S.Number, { exact: true }) } is executed.
This approach allows for immediate handling of the input, potentially influencing the subsequent transformations.