Class APIs
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
Classimplementing Data.Class).
Definition
To define a class using Schema.Class, you need to specify:
- The type of the class being created.
- A unique identifier for the class.
- The desired fields.
Example (Defining a Schema Class)
import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) {}In this example, Person is both a schema and a TypeScript class. Instances of Person are created using the defined schema, ensuring compliance with the specified fields.
Example (Creating Instances)
import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) {}
console.log(new Person({ id: 1, name: "John" }))/*Output:Person { id: 1, name: 'John' }*/
// Using the factory functionconsole.log(Person.make({ id: 1, name: "John" }))/*Output:Person { id: 1, name: 'John' }*/Class Schemas are Transformations
Class schemas transform a struct schema into a declaration schema that represents a class type.
- When decoding, a plain object is converted into an instance of the class.
- When encoding, a class instance is converted back into a plain object.
Example (Decoding and Encoding a Class)
import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) {}
const person = Person.make({ id: 1, name: "John" })
// Decode from a plain object into a class instanceconst decoded = Schema.decodeUnknownSync(Person)({ id: 1, name: "John" })console.log(decoded)// Output: Person { id: 1, name: 'John' }
// Encode a class instance back into a plain objectconst encoded = Schema.encodeUnknownSync(Person)(person)console.log(encoded)// Output: { id: 1, name: 'John' }Defining Classes Without Fields
When your schema does not require any fields, you can define a class with an empty object.
Example (Defining and Using a Class Without Arguments)
import { Schema } from "effect"
// Define a class with no fieldsclass NoArgs extends Schema.Class<NoArgs>("NoArgs")({}) {}
// Create an instance using the default constructorconst noargs1 = new NoArgs()
// Alternatively, create an instance by explicitly passing an empty objectconst noargs2 = new NoArgs({})Defining Classes With Filters
Filters allow you to validate input when decoding, encoding, or creating an instance. Instead of specifying raw fields, you can pass a Schema.Struct with a filter applied.
Example (Applying a Filter to a Schema Class)
import { Schema } from "effect"
class WithFilter extends Schema.Class<WithFilter>("WithFilter")( Schema.Struct({ a: Schema.NumberFromString, b: Schema.NumberFromString, }).pipe(Schema.filter(({ a, b }) => a >= b || "a must be greater than b")),) {}
// Constructorconsole.log(new WithFilter({ a: 1, b: 2 }))/*throws:ParseError: WithFilter (Constructor)โโ Predicate refinement failure โโ a must be greater than b*/
// Decodingconsole.log(Schema.decodeUnknownSync(WithFilter)({ a: "1", b: "2" }))/*throws:ParseError: (WithFilter (Encoded side) <-> WithFilter)โโ Encoded side transformation failure โโ WithFilter (Encoded side) โโ Predicate refinement failure โโ a must be greater than b*/Validating Properties via Class Constructors
When you define a class using Schema.Class, the constructor automatically checks that the provided properties adhere to the schemaโs rules.
Defining and Instantiating a Valid Class Instance
The constructor ensures that each property, like id and name, adheres to the schema. For instance, id must be a number, and name must be a non-empty string.
Example (Creating a Valid Instance)
import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) {}
// Create an instance with valid propertiesconst john = new Person({ id: 1, name: "John" })Handling Invalid Properties
If invalid properties are provided during instantiation, the constructor throws an error, explaining why the validation failed.
Example (Creating an Instance with Invalid Properties)
import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) {}
// Attempt to create an instance with an invalid `name`new Person({ id: 1, name: "" })/*throws:ParseError: Person (Constructor)โโ ["name"] โโ NonEmptyString โโ Predicate refinement failure โโ Expected NonEmptyString, actual ""*/The error clearly specifies that the name field failed to meet the NonEmptyString requirement.
Bypassing Validation
In some scenarios, you might want to bypass the validation logic. While not generally recommended, the library provides an option to do so.
Example (Bypassing Validation)
import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) {}
// Bypass validation during instantiationconst john = new Person({ id: 1, name: "" }, true)
// Or use the `disableValidation` option explicitlynew Person({ id: 1, name: "" }, { disableValidation: true })Automatic Hashing and Equality in Classes
Instances of classes created with Schema.Class support the Equal trait through their integration with Data.Class. This enables straightforward value comparisons, even across different instances.
Basic Equality Check
Two class instances are considered equal if their properties have identical values.
Example (Comparing Instances with Equal Properties)
import { Schema } from "effect"import { Equal } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) {}
const john1 = new Person({ id: 1, name: "John" })const john2 = new Person({ id: 1, name: "John" })
// Compare instancesconsole.log(Equal.equals(john1, john2))// Output: trueNested or Complex Properties
The Equal trait performs comparisons at the first level. If a property is a more complex structure, such as an array, instances may not be considered equal, even if the arrays themselves have identical values.
Example (Shallow Equality for Arrays)
import { Schema } from "effect"import { Equal } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString, hobbies: Schema.Array(Schema.String), // Standard array schema}) {}
const john1 = new Person({ id: 1, name: "John", hobbies: ["reading", "coding"],})const john2 = new Person({ id: 1, name: "John", hobbies: ["reading", "coding"],})
// Equality fails because `hobbies` are not deeply comparedconsole.log(Equal.equals(john1, john2))// Output: falseTo achieve deep equality for nested structures like arrays, use Schema.Data in combination with Data.array. This enables the library to compare each element of the array rather than treating it as a single entity.
Example (Using Schema.Data for Deep Equality)
import { Schema } from "effect"import { Data, Equal } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString, hobbies: Schema.Data(Schema.Array(Schema.String)), // Enable deep equality}) {}
const john1 = new Person({ id: 1, name: "John", hobbies: Data.array(["reading", "coding"]),})const john2 = new Person({ id: 1, name: "John", hobbies: Data.array(["reading", "coding"]),})
// Equality succeeds because `hobbies` are deeply comparedconsole.log(Equal.equals(john1, john2))// Output: trueExtending Classes with Custom Logic
Schema classes provide the flexibility to include custom getters and methods, allowing you to extend their functionality beyond the defined fields.
Adding Custom Getters
A getter can be used to derive computed values from the fields of the class. For example, a Person class can include a getter to return the name property in uppercase.
Example (Adding a Getter for Uppercase Name)
import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) { // Custom getter to return the name in uppercase get upperName() { return this.name.toUpperCase() }}
const john = new Person({ id: 1, name: "John" })
// Use the custom getterconsole.log(john.upperName)// Output: "JOHN"Adding Custom Methods
In addition to getters, you can define methods to encapsulate more complex logic or operations involving the classโs fields.
Example (Adding a Method)
import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) { // Custom method to return a greeting greet() { return `Hello, my name is ${this.name}.` }}
const john = new Person({ id: 1, name: "John" })
// Use the custom methodconsole.log(john.greet())// Output: "Hello, my name is John."Leveraging Classes as Schema Definitions
When you define a class with Schema.Class, it serves both as a schema and as a class. This dual functionality allows the class to be used wherever a schema is required.
Example (Using a Class in an Array Schema)
import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) {}
// Use the Person class in an array schemaconst Persons = Schema.Array(Person)
// โโโโ readonly Person[]// โผtype Type = typeof Persons.TypeExposed Values
The class also includes a fields static property, which outlines the fields defined during the class creation.
Example (Accessing the fields Property)
import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) {}
// โโโโ {// | readonly id: typeof Schema.Number;// | readonly name: typeof Schema.NonEmptyString;// | }// โผPerson.fieldsAdding Annotations
Defining a class with Schema.Class is similar to creating a transformation schema that converts a struct schema into a declaration schema representing the class type.
For example, consider the following class definition:
import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) {}Under the hood, this definition creates a transformation schema that maps:
Schema.Struct({ id: Schema.Number, name: Schema.NonEmptyString,})to a schema representing the Person class:
Schema.declare((input) => input instanceof Person)So, defining a schema with Schema.Class involves three schemas:
- The โfromโ schema (the struct)
- The โtoโ schema (the class)
- The โtransformationโ schema (struct -> class)
You can annotate each of these schemas by passing a tuple as the second argument to the Schema.Class API.
Example (Annotating Different Parts of the Class Schema)
import { Schema, SchemaAST } from "effect"
class Person extends Schema.Class<Person>("Person")( { id: Schema.Number, name: Schema.NonEmptyString, }, [ // Annotations for the "to" schema { description: `"to" description` },
// Annotations for the "transformation schema { description: `"transformation" description` },
// Annotations for the "from" schema { description: `"from" description` }, ],) {}
console.log(SchemaAST.getDescriptionAnnotation(Person.ast.to))// Output: { _id: 'Option', _tag: 'Some', value: '"to" description' }
console.log(SchemaAST.getDescriptionAnnotation(Person.ast))// Output: { _id: 'Option', _tag: 'Some', value: '"transformation" description' }
console.log(SchemaAST.getDescriptionAnnotation(Person.ast.from))// Output: { _id: 'Option', _tag: 'Some', value: '"from" description' }If you do not want to annotate all three schemas, you can pass undefined for the ones you wish to skip.
Example (Skipping Annotations)
import { Schema, SchemaAST } from "effect"
class Person extends Schema.Class<Person>("Person")( { id: Schema.Number, name: Schema.NonEmptyString, }, [ // No annotations for the "to" schema undefined,
// Annotations for the "transformation schema { description: `"transformation" description` }, ],) {}
console.log(SchemaAST.getDescriptionAnnotation(Person.ast.to))// Output: { _id: 'Option', _tag: 'None' }
console.log(SchemaAST.getDescriptionAnnotation(Person.ast))// Output: { _id: 'Option', _tag: 'Some', value: '"transformation" description' }
console.log(SchemaAST.getDescriptionAnnotation(Person.ast.from))// Output: { _id: 'Option', _tag: 'None' }By default, the unique identifier used to define the class is also applied as the default identifier annotation for the Class Schema.
Example (Default Identifier Annotation)
import { Schema, SchemaAST } from "effect"
// Used as default identifier annotation โโโโโ// |// โผclass Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) {}
console.log(SchemaAST.getIdentifierAnnotation(Person.ast.to))// Output: { _id: 'Option', _tag: 'Some', value: 'Person' }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.
Example (Self-Referencing Schema)
import { Schema } from "effect"
// Define a Category schema with a recursive subcategories fieldclass Category extends Schema.Class<Category>("Category")({ name: Schema.String, subcategories: Schema.Array(Schema.suspend((): Schema.Schema<Category> => Category)),}) {}Example (Missing Type Annotation Error)
import { Schema } from "effect"
// @errors: 2506 7024class Category extends Schema.Class<Category>("Category")({ name: Schema.String, subcategories: Schema.Array(Schema.suspend(() => Category)),}) {}Mutually Recursive Schemas
Sometimes, schemas depend on each other in a mutually recursive way. For instance, an arithmetic expression tree might include Expression nodes that can either be numbers or Operation nodes, which in turn reference Expression nodes.
Example (Arithmetic Expression Tree)
import { Schema } from "effect"
class Expression extends Schema.Class<Expression>("Expression")({ type: Schema.Literal("expression"), value: Schema.Union( Schema.Number, Schema.suspend((): Schema.Schema<Operation> => Operation), ),}) {}
class Operation extends Schema.Class<Operation>("Operation")({ type: Schema.Literal("operation"), operator: Schema.Literal("+", "-"), left: Expression, right: Expression,}) {}Recursive Types with Different Encoded and Type
Defining recursive schemas where the Encoded type differs from the Type type introduces additional complexity. For instance, if a schema includes fields that transform data (e.g., NumberFromString), the Encoded and Type types may not align.
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:
import { Schema } from "effect"
class Category extends Schema.Class<Category>("Category")({ id: Schema.NumberFromString, name: Schema.String, subcategories: Schema.Array( // @errors: 2322 Schema.suspend((): Schema.Schema<Category> => Category), ),}) {}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:
Example (Adjusting the Schema with Explicit Encoded Type)
import { Schema } from "effect"
interface CategoryEncoded { readonly id: string readonly name: string readonly subcategories: ReadonlyArray<CategoryEncoded>}
class Category extends Schema.Class<Category>("Category")({ id: Schema.NumberFromString, name: Schema.String, subcategories: Schema.Array( Schema.suspend((): Schema.Schema<Category, CategoryEncoded> => Category), ),}) {}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.
Example (Separating Recursive Field)
import { Schema } from "effect"
const fields = { id: Schema.NumberFromString, name: Schema.String, // ...possibly other fields}
interface CategoryEncoded extends Schema.Struct.Encoded<typeof fields> { // Define `subcategories` using recursion readonly subcategories: ReadonlyArray<CategoryEncoded>}
class Category extends Schema.Class<Category>("Category")({ ...fields, // Include the fields subcategories: Schema.Array( // Define `subcategories` using recursion Schema.suspend((): Schema.Schema<Category, CategoryEncoded> => Category), ),}) {}Tagged Class variants
You can also create classes that extend TaggedClass and TaggedError from the effect/Data module.
Example (Creating Tagged Classes and Errors)
import { Schema } from "effect"
// Define a tagged class with a "name" fieldclass TaggedPerson extends Schema.TaggedClass<TaggedPerson>()("TaggedPerson", { name: Schema.String,}) {}
// Define a tagged error with a "status" fieldclass HttpError extends Schema.TaggedError<HttpError>()("HttpError", { status: Schema.Number,}) {}
const joe = new TaggedPerson({ name: "Joe" })console.log(joe._tag)// Output: "TaggedPerson"
const error = new HttpError({ status: 404 })console.log(error._tag)// Output: "HttpError"
console.log(error.stack) // access the stack traceExtending existing Classes
The extend static utility allows you to enhance an existing schema class by adding additional fields and functionality. This approach helps in building on top of existing schemas without redefining them from scratch.
Example (Extending a Schema Class)
import { Schema } from "effect"
// Define the base classclass Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) { // A custom getter that converts the name to uppercase get upperName() { return this.name.toUpperCase() }}
// Extend the base class to include an "age" fieldclass PersonWithAge extends Person.extend<PersonWithAge>("PersonWithAge")({ age: Schema.Number,}) { // A custom getter to check if the person is an adult get isAdult() { return this.age >= 18 }}
// Usageconst john = new PersonWithAge({ id: 1, name: "John", age: 25 })console.log(john.upperName) // Output: "JOHN"console.log(john.isAdult) // Output: trueNote that you can only add additional fields when extending a class.
Example (Attempting to Overwrite Existing Fields)
import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.NonEmptyString,}) { get upperName() { return this.name.toUpperCase() }}
class BadExtension extends Person.extend<BadExtension>("BadExtension")({ name: Schema.Number,}) {}/*throws:Error: Duplicate property signaturedetails: Duplicate key "name"*/This error occurs because allowing fields to be overwritten is not safe. It could interfere with any getters or methods defined on the class that rely on the original definition. For example, in this case, the upperName getter would break if the name field was changed to a number.
Transformations
You can enhance schema classes with effectful transformations to enrich or validate entities, particularly when working with data sourced from external systems like databases or APIs.
Example (Effectful Transformation)
The following example demonstrates adding an age field to a Person class. The age value is derived asynchronously based on the id field.
import { Effect, Option, Schema, ParseResult } from "effect"
// Base class definitionclass Person extends Schema.Class<Person>("Person")({ id: Schema.Number, name: Schema.String,}) {}
console.log(Schema.decodeUnknownSync(Person)({ id: 1, name: "name" }))/*Output:Person { id: 1, name: 'name' }*/
// Simulate fetching age asynchronously based on idfunction getAge(id: number): Effect.Effect<number, Error> { return Effect.succeed(id + 2)}
// Extended class with a transformationclass PersonWithTransform extends Person.transformOrFail<PersonWithTransform>( "PersonWithTransform",)( { age: Schema.optionalWith(Schema.Number, { exact: true, as: "Option" }), }, { // Decoding logic for the new field decode: (input) => Effect.mapBoth(getAge(input.id), { onFailure: (e) => new ParseResult.Type(Schema.String.ast, input.id, e.message), // Must return { age: Option<number> } onSuccess: (age) => ({ ...input, age: Option.some(age) }), }), encode: ParseResult.succeed, },) {}
Schema.decodeUnknownPromise(PersonWithTransform)({ id: 1, name: "name",}).then(console.log)/*Output:PersonWithTransform { id: 1, name: 'name', age: { _id: 'Option', _tag: 'Some', value: 3 }}*/
// Extended class with a conditional Transformationclass PersonWithTransformFrom extends Person.transformOrFailFrom<PersonWithTransformFrom>( "PersonWithTransformFrom",)( { age: Schema.optionalWith(Schema.Number, { exact: true, as: "Option" }), }, { decode: (input) => Effect.mapBoth(getAge(input.id), { onFailure: (e) => new ParseResult.Type(Schema.String.ast, input, e.message), // Must return { age?: number } onSuccess: (age) => (age > 18 ? { ...input, age } : { ...input }), }), encode: ParseResult.succeed, },) {}
Schema.decodeUnknownPromise(PersonWithTransformFrom)({ id: 1, name: "name",}).then(console.log)/*Output:PersonWithTransformFrom { id: 1, name: 'name', age: { _id: 'Option', _tag: 'None' }}*/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
Schema.optionalWith(Schema.Number, { exact: true, as: "Option" })is executed. - This approach allows for immediate handling of the input, potentially influencing the subsequent transformations.