Advanced Usage
Declaring New Data Types
Primitive Data Types
To declare a schema for a primitive data type, such as File, you can use the Schema.declare function along with a type guard.
Example (Declaring a Schema for File)
import { Schema } from "effect"
// Declare a schema for the File type using a type guardconst FileFromSelf = Schema.declare((input: unknown): input is File => input instanceof File)
const decode = Schema.decodeUnknownSync(FileFromSelf)
// Decoding a valid File objectconsole.log(decode(new File([], "")))/*Output:File { size: 0, type: '', name: '', lastModified: 1724774163056 }*/
// Decoding an invalid inputdecode(null)/*throwsParseError: Expected <declaration schema>, actual null*/To enhance the default error message, you can add annotations, particularly the identifier, title, and description annotations (none of these annotations are required, but they are encouraged for good practice and can make your schema “self-documenting”). These annotations will be utilized by the messaging system to return more meaningful messages.
- Identifier: a unique name for the schema
- Title: a brief, descriptive title
- Description: a detailed explanation of the schema’s purpose
Example (Declaring a Schema with Annotations)
import { Schema } from "effect"
// Declare a schema for the File type with additional annotationsconst FileFromSelf = Schema.declare((input: unknown): input is File => input instanceof File, { // A unique identifier for the schema identifier: "FileFromSelf", // Detailed description of the schema description: "The `File` type in JavaScript",})
const decode = Schema.decodeUnknownSync(FileFromSelf)
// Decoding a valid File objectconsole.log(decode(new File([], "")))/*Output:File { size: 0, type: '', name: '', lastModified: 1724774163056 }*/
// Decoding an invalid inputdecode(null)/*throwsParseError: Expected FileFromSelf, actual null*/Type Constructors
Type constructors are generic types that take one or more types as arguments and return a new type. To define a schema for a type constructor, you can use the Schema.declare function.
Example (Declaring a Schema for ReadonlySet<A>)
import { ParseResult, Schema } from "effect"
export const MyReadonlySet = <A, I, R>( // Schema for the elements of the Set item: Schema.Schema<A, I, R>,): Schema.Schema<ReadonlySet<A>, ReadonlySet<I>, R> => Schema.declare( // Store the schema for the Set's elements [item], { // Decoding function decode: (item) => (input, parseOptions, ast) => { if (input instanceof Set) { // Decode each element in the Set const elements = ParseResult.decodeUnknown(Schema.Array(item))( Array.from(input.values()), parseOptions, ) // Return a ReadonlySet containing the decoded elements return ParseResult.map(elements, (as): ReadonlySet<A> => new Set(as)) } // Handle invalid input return ParseResult.fail(new ParseResult.Type(ast, input)) }, // Encoding function encode: (item) => (input, parseOptions, ast) => { if (input instanceof Set) { // Encode each element in the Set const elements = ParseResult.encodeUnknown(Schema.Array(item))( Array.from(input.values()), parseOptions, ) // Return a ReadonlySet containing the encoded elements return ParseResult.map(elements, (is): ReadonlySet<I> => new Set(is)) } // Handle invalid input return ParseResult.fail(new ParseResult.Type(ast, input)) }, }, { description: `ReadonlySet<${Schema.format(item)}>`, }, )
// Define a schema for a ReadonlySet of numbersconst setOfNumbers = MyReadonlySet(Schema.NumberFromString)
const decode = Schema.decodeUnknownSync(setOfNumbers)
console.log(decode(new Set(["1", "2", "3"]))) // Set(3) { 1, 2, 3 }
// Decode an invalid inputdecode(null)/*throwsParseError: Expected ReadonlySet<NumberFromString>, actual null*/
// Decode a Set with an invalid elementdecode(new Set(["1", null, "3"]))/*throwsParseError: ReadonlyArray<NumberFromString>└─ [1] └─ NumberFromString └─ Encoded side transformation failure └─ Expected string, actual null*/Adding Compilers Annotations
When defining a new data type, some compilers like Arbitrary or Pretty may not know how to handle the new type. This can result in an error, as the compiler may lack the necessary information for generating instances or producing readable output:
Example (Attempting to Generate Arbitrary Values Without Required Annotations)
import { Arbitrary, Schema } from "effect"
// Define a schema for the File typeconst FileFromSelf = Schema.declare((input: unknown): input is File => input instanceof File, { identifier: "FileFromSelf",})
// Try creating an Arbitrary instance for the schemaconst arb = Arbitrary.make(FileFromSelf)/*throws:Error: Missing annotationdetails: Generating an Arbitrary for this schema requires an "arbitrary" annotationschema (Declaration): FileFromSelf*/In the above example, attempting to generate arbitrary values for the FileFromSelf schema fails because the compiler lacks necessary annotations. To resolve this, you need to provide annotations for generating arbitrary data:
Example (Adding Arbitrary Annotation for Custom File Schema)
import { Arbitrary, FastCheck, Pretty, Schema } from "effect"
const FileFromSelf = Schema.declare((input: unknown): input is File => input instanceof File, { identifier: "FileFromSelf", // Provide a function to generate random File instances arbitrary: () => (fc) => fc.tuple(fc.string(), fc.string()).map(([content, path]) => new File([content], path)),})
// Create an Arbitrary instance for the schemaconst arb = Arbitrary.make(FileFromSelf)
// Generate sample files using the Arbitrary instanceconst files = FastCheck.sample(arb, 2)console.log(files)/*Example Output:[ File { size: 5, type: '', name: 'C', lastModified: 1706435571176 }, File { size: 1, type: '', name: '98Ggmc', lastModified: 1706435571176 }]*/For more details on how to add annotations for the Arbitrary compiler, refer to the Arbitrary documentation.
Branded types
TypeScript’s type system is structural, which means that any two types that are structurally equivalent are considered the same. This can cause issues when types that are semantically different are treated as if they were the same.
Example (Structural Typing Issue)
type UserId = stringtype Username = string
declare const getUser: (id: UserId) => object
const myUsername: Username = "gcanti"
getUser(myUsername) // This erroneously worksIn the above example, UserId and Username are both aliases for the same type, string. This means that the getUser function can mistakenly accept a Username as a valid UserId, causing bugs and errors.
To prevent this, Effect introduces branded types. These types attach a unique identifier (or “brand”) to a type, allowing you to differentiate between structurally similar but semantically distinct types.
Example (Defining Branded Types)
import { Brand } from "effect"
type UserId = string & Brand.Brand<"UserId">type Username = string
declare const getUser: (id: UserId) => object
const myUsername: Username = "gcanti"
// @errors: 2345getUser(myUsername)By defining UserId as a branded type, the getUser function can accept only values of type UserId, and not plain strings or other types that are compatible with strings. This helps to prevent bugs caused by accidentally passing the wrong type of value to the function.
There are two ways to define a schema for a branded type, depending on whether you:
- want to define the schema from scratch
- have already defined a branded type via
effect/Brandand want to reuse it to define a schema
Defining a brand schema from scratch
To define a schema for a branded type from scratch, use the Schema.brand function.
Example (Creating a schema for a Branded Type)
import { Schema } from "effect"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
// string & Brand<"UserId">type UserId = typeof UserId.TypeNote that you can use unique symbols as brands to ensure uniqueness across modules / packages.
Example (Using a unique symbol as a Brand)
import { Schema } from "effect"
const UserIdBrand: unique symbol = Symbol.for("UserId")
const UserId = Schema.String.pipe(Schema.brand(UserIdBrand))
// string & Brand<typeof UserIdBrand>type UserId = typeof UserId.TypeReusing an existing branded constructor
If you have already defined a branded type using the effect/Brand module, you can reuse it to define a schema using the Schema.fromBrand function.
Example (Reusing an Existing Branded Type)
import { Schema } from "effect"import { Brand } from "effect"
// the existing branded typetype UserId = string & Brand.Brand<"UserId">
const UserId = Brand.nominal<UserId>()
// Define a schema for the branded typeconst UserIdSchema = Schema.String.pipe(Schema.fromBrand(UserId))Utilizing Default Constructors
The Schema.brand function includes a default constructor to facilitate the creation of branded values.
import { Schema } from "effect"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
const userId = UserId.make("123") // Creates a branded UserIdProperty Signatures
A PropertySignature represents a transformation from a “From” field to a “To” field. This allows you to define mappings between incoming data fields and your internal model.
Basic Usage
A property signature can be defined with annotations to provide additional context about a field.
Example (Adding Annotations to a Property Signature)
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.propertySignature(Schema.NumberFromString).annotations({ title: "Age", // Annotation to label the age field }),})A PropertySignature type contains several parameters, each providing details about the transformation between the source field (From) and the target field (To). Let’s take a look at what each of these parameters represents:
age: PropertySignature<ToToken, ToType, FromKey, FromToken, FromType, HasDefault, Context>| Parameter | Description |
|---|---|
age |
Key of the “To” field |
ToToken |
Indicates field requirement: "?:" for optional, ":" for required |
ToType |
Type of the “To” field |
FromKey |
(Optional, default = never) Indicates the source field key, typically the same as “To” field key unless specified |
FromToken |
Indicates source field requirement: "?:" for optional, ":" for required |
FromType |
Type of the “From” field |
HasDefault |
Indicates if there is a constructor default value (Boolean) |
In the example above, the PropertySignature type for age is:
PropertySignature<":", number, never, ":", string, false, never>This means:
| Parameter | Description |
|---|---|
age |
Key of the “To” field |
ToToken |
":" indicates that the age field is required |
ToType |
Type of the age field is number |
FromKey |
never indicates that the decoding occurs from the same field named age |
FromToken |
":" indicates that the decoding occurs from a required age field |
FromType |
Type of the “From” field is string |
HasDefault |
false: indicates there is no default value |
Sometimes, the source field (the “From” field) may have a different name from the field in your internal model. You can map between these fields using the Schema.fromKey function.
Example (Mapping from a Different Key)
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.propertySignature(Schema.NumberFromString).pipe( Schema.fromKey("AGE"), // Maps from "AGE" to "age" ),})
console.log(Schema.decodeUnknownSync(Person)({ name: "name", AGE: "18" }))// Output: { name: 'name', age: 18 }When you map from "AGE" to "age", the PropertySignature type changes to:
PropertySignature<":", number, never, ":", string, false, never>PropertySignature<":", number, "AGE", ":", string, false, never>Optional Fields
Basic Optional Property
The syntax:
Schema.optional(schema: Schema<A, I, R>)creates an optional property within a schema, allowing fields to be omitted or set to undefined.
Decoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
undefined |
remains undefined |
i: I |
transforms to a: A |
Encoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
undefined |
remains undefined |
a: A |
transforms back to i: I |
Example (Defining an Optional Number Field)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optional(Schema.NumberFromString),})
// ┌─── { readonly quantity?: string | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity?: number | undefined; }// ▼type Type = typeof Product.Type
// Decoding examples
console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" }))// Output: { quantity: 1 }console.log(Schema.decodeUnknownSync(Product)({}))// Output: {}console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: undefined }
// Encoding examples
console.log(Schema.encodeSync(Product)({ quantity: 1 }))// Output: { quantity: "1" }console.log(Schema.encodeSync(Product)({}))// Output: {}console.log(Schema.encodeSync(Product)({ quantity: undefined }))// Output: { quantity: undefined }Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optional(Schema.NumberFromString),})
// ┌─── typeof Schema.NumberFromString// ▼const from = Product.fields.quantity.fromOptional with Nullability
The syntax:
Schema.optionalWith(schema: Schema<A, I, R>, { nullable: true })creates an optional property within a schema, treating null values the same as missing values.
Decoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
undefined |
remains undefined |
null |
transforms to <missing value> |
i: I |
transforms to a: A |
Encoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
undefined |
remains undefined |
a: A |
transforms back to i: I |
Example (Handling Null as Missing Value)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { nullable: true, }),})
// ┌─── { readonly quantity?: string | null | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity?: number | undefined; }// ▼type Type = typeof Product.Type
// Decoding examples
console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" }))// Output: { quantity: 1 }console.log(Schema.decodeUnknownSync(Product)({}))// Output: {}console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: undefined }console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: {}
// Encoding examples
console.log(Schema.encodeSync(Product)({ quantity: 1 }))// Output: { quantity: "1" }console.log(Schema.encodeSync(Product)({}))// Output: {}console.log(Schema.encodeSync(Product)({ quantity: undefined }))// Output: { quantity: undefined }Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { nullable: true, }),})
// ┌─── typeof Schema.NumberFromString// ▼const from = Product.fields.quantity.fromOptional with Exactness
The syntax:
Schema.optionalWith(schema: Schema<A, I, R>, { exact: true })creates an optional property while enforcing strict typing. This means that only the specified type (excluding undefined) is accepted. Any attempt to decode undefined results in an error.
Decoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
undefined |
ParseError |
i: I |
transforms to a: A |
Encoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
a: A |
transforms back to i: I |
Example (Using Exactness with Optional Field)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { exact: true }),})
// ┌─── { readonly quantity?: string; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity?: number; }// ▼type Type = typeof Product.Type
// Decoding examples
console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" }))// Output: { quantity: 1 }console.log(Schema.decodeUnknownSync(Product)({}))// Output: {}console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))/*throws:ParseError: { readonly quantity?: NumberFromString }└─ ["quantity"] └─ NumberFromString └─ Encoded side transformation failure └─ Expected string, actual undefined*/
// Encoding examples
console.log(Schema.encodeSync(Product)({ quantity: 1 }))// Output: { quantity: "1" }console.log(Schema.encodeSync(Product)({}))// Output: {}Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { exact: true }),})
// ┌─── typeof Schema.NumberFromString// ▼const from = Product.fields.quantity.fromCombining Nullability and Exactness
The syntax:
Schema.optionalWith(schema: Schema<A, I, R>, { exact: true, nullable: true })allows you to define an optional property that enforces strict typing (exact type only) while also treating null as equivalent to a missing value.
Decoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
null |
transforms to <missing value> |
undefined |
ParseError |
i: I |
transforms to a: A |
Encoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
a: A |
transforms back to i: I |
Example (Using Exactness and Handling Null as Missing Value with Optional Field)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { exact: true, nullable: true, }),})
// ┌─── { readonly quantity?: string | null; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity?: number; }// ▼type Type = typeof Product.Type
// Decoding examples
console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" }))// Output: { quantity: 1 }console.log(Schema.decodeUnknownSync(Product)({}))// Output: {}console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))/*throws:ParseError: (Struct (Encoded side) <-> Struct (Type side))└─ Encoded side transformation failure └─ Struct (Encoded side) └─ ["quantity"] └─ NumberFromString | null ├─ NumberFromString │ └─ Encoded side transformation failure │ └─ Expected string, actual undefined └─ Expected null, actual undefined*/console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: {}
// Encoding examples
console.log(Schema.encodeSync(Product)({ quantity: 1 }))// Output: { quantity: "1" }console.log(Schema.encodeSync(Product)({}))// Output: {}Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { exact: true, nullable: true, }),})
// ┌─── typeof Schema.NumberFromString// ▼const from = Product.fields.quantity.fromRepresenting Optional Fields with never Type
When creating a schema to replicate a TypeScript type that includes optional fields with the never type, like:
type MyType = { readonly quantity?: never}the handling of these fields depends on the exactOptionalPropertyTypes setting in your tsconfig.json.
This setting affects whether the schema should treat optional never-typed fields as simply absent or allow undefined as a value.
Example (exactOptionalPropertyTypes: false)
When this feature is turned off, you can employ the Schema.optional function. This approach allows the field to implicitly accept undefined as a value.
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optional(Schema.Never),})
// ┌─── { readonly quantity?: undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity?: undefined; }// ▼type Type = typeof Product.TypeExample (exactOptionalPropertyTypes: true)
When this feature is turned on, the Schema.optionalWith function is recommended.
It ensures stricter enforcement of the field’s absence.
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.Never, { exact: true }),})
// ┌─── { readonly quantity?: never; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity?: never; }// ▼type Type = typeof Product.TypeDefault Values
The default option in Schema.optionalWith allows you to set default values that are applied during both decoding and object construction phases.
This feature ensures that even if certain properties are not provided by the user, the system will automatically use the specified default values.
The Schema.optionalWith function offers several ways to control how defaults are applied during decoding and encoding. You can fine-tune whether defaults are applied only when the input is completely missing, or even when null or undefined values are provided.
Basic Default
This is the simplest use case. If the input is missing or undefined, the default value will be applied.
Syntax
Schema.optionalWith(schema: Schema<A, I, R>, { default: () => A })| Operation | Behavior |
|---|---|
| Decoding | Applies the default value if the input is missing or undefined |
| Encoding | Transforms the input a: A back to i: I |
Example (Applying Default When Field Is Missing or undefined)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { default: () => 1, // Default value for quantity }),})
// ┌─── { readonly quantity?: string | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: number; }// ▼type Type = typeof Product.Type
// Decoding examples with default applied
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: 2 }
// Object construction examples with default applied
console.log(Product.make({}))// Output: { quantity: 1 }
console.log(Product.make({ quantity: 2 }))// Output: { quantity: 2 }Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { default: () => 1, // Default value for quantity }),})
// ┌─── typeof Schema.NumberFromString// ▼const from = Product.fields.quantity.fromDefault with Exactness
When you want the default value to be applied only if the field is completely missing (not when it’s undefined), you can use the exact option.
Syntax
Schema.optionalWith(schema: Schema<A, I, R>, { default: () => A, exact: true})| Operation | Behavior |
|---|---|
| Decoding | Applies the default value only if the input is missing |
| Encoding | Transforms the input a: A back to i: I |
Example (Applying Default Only When Field Is Missing)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { default: () => 1, // Default value for quantity exact: true, // Only apply default if quantity is not provided }),})
// ┌─── { readonly quantity?: string; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: number; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: 2 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))/*throws:ParseError: (Struct (Encoded side) <-> Struct (Type side))└─ Encoded side transformation failure └─ Struct (Encoded side) └─ ["quantity"] └─ NumberFromString └─ Encoded side transformation failure └─ Expected string, actual undefined*/Default with Nullability
In cases where you want null values to trigger the default behavior, you can use the nullable option. This ensures that if a field is set to null, it will be replaced by the default value.
Syntax
Schema.optionalWith(schema: Schema<A, I, R>, { default: () => A, nullable: true})| Operation | Behavior |
|---|---|
| Decoding | Applies the default value if the input is missing or undefined or null |
| Encoding | Transforms the input a: A back to i: I |
Example (Applying Default When Field Is Missing or undefined or null)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { default: () => 1, // Default value for quantity nullable: true, // Apply default if quantity is null }),})
// ┌─── { readonly quantity?: string | null | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: number; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: 2 }Combining Exactness and Nullability
For a more strict approach, you can combine both exact and nullable options. This way, the default value is applied only when the field is null or missing, and not when it’s explicitly set to undefined.
Syntax
Schema.optionalWith(schema: Schema<A, I, R>, { default: () => A, exact: true, nullable: true})| Operation | Behavior |
|---|---|
| Decoding | Applies the default value if the input is missing or null |
| Encoding | Transforms the input a: A back to i: I |
Example (Applying Default Only When Field Is Missing or null)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { default: () => 1, // Default value for quantity exact: true, // Only apply default if quantity is not provided nullable: true, // Apply default if quantity is null }),})
// ┌─── { readonly quantity?: string | null; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: number; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: 2 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))/*throws:ParseError: (Struct (Encoded side) <-> Struct (Type side))└─ Encoded side transformation failure └─ Struct (Encoded side) └─ ["quantity"] └─ NumberFromString └─ Encoded side transformation failure └─ Expected string, actual undefined*/Optional Fields as Options
When working with optional fields, you may want to handle them as Option types. This approach allows you to explicitly manage the presence or absence of a field rather than relying on undefined or null.
Basic Optional with Option Type
You can configure a schema to treat optional fields as Option types, where missing or undefined values are converted to Option.none() and existing values are wrapped in Option.some().
Syntax
optionalWith(schema: Schema<A, I, R>, { as: "Option" })Decoding
| Input | Output |
|---|---|
<missing value> |
transforms to Option.none() |
undefined |
transforms to Option.none() |
i: I |
transforms to Option.some(a: A) |
Encoding
| Input | Output |
|---|---|
Option.none() |
transforms to <missing value> |
Option.some(a: A) |
transforms back to i: I |
Example (Handling Optional Field as Option)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option" }),})
// ┌─── { readonly quantity?: string | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: Option<number>; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option" }),})
// ┌─── typeof Schema.NumberFromString// ▼const from = Product.fields.quantity.fromOptional with Exactness
The exact option ensures that the default behavior of the optional field applies only when the field is entirely missing, not when it is undefined.
Syntax
optionalWith(schema: Schema<A, I, R>, { as: "Option", exact: true})Decoding
| Input | Output |
|---|---|
<missing value> |
transforms to Option.none() |
undefined |
ParseError |
i: I |
transforms to Option.some(a: A) |
Encoding
| Input | Output |
|---|---|
Option.none() |
transforms to <missing value> |
Option.some(a: A) |
transforms back to i: I |
Example (Using Exactness with Optional Field as Option)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option", exact: true, }),})
// ┌─── { readonly quantity?: string; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: Option<number>; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))/*throws:ParseError: (Struct (Encoded side) <-> Struct (Type side))└─ Encoded side transformation failure └─ Struct (Encoded side) └─ ["quantity"] └─ NumberFromString └─ Encoded side transformation failure └─ Expected string, actual undefined*/Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option", exact: true, }),})
// ┌─── typeof Schema.NumberFromString// ▼const from = Product.fields.quantity.fromOptional with Nullability
The nullable option extends the default behavior to treat null as equivalent to Option.none(), alongside missing or undefined values.
Syntax
optionalWith(schema: Schema<A, I, R>, { as: "Option", nullable: true})Decoding
| Input | Output |
|---|---|
<missing value> |
transforms to Option.none() |
undefined |
transforms to Option.none() |
null |
transforms to Option.none() |
i: I |
transforms to Option.some(a: A) |
Encoding
| Input | Output |
|---|---|
Option.none() |
transforms to <missing value> |
Option.some(a: A) |
transforms back to i: I |
Example (Handling Null as Missing Value with Optional Field as Option)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option", nullable: true, }),})
// ┌─── { readonly quantity?: string | null | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: Option<number>; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option", nullable: true, }),})
// ┌─── typeof Schema.NumberFromString// ▼const from = Product.fields.quantity.fromCombining Exactness and Nullability
When both exact and nullable options are used together, only null and missing fields are treated as Option.none(), while undefined is considered an invalid value.
Syntax
optionalWith(schema: Schema<A, I, R>, { as: "Option", exact: true, nullable: true})Decoding
| Input | Output |
|---|---|
<missing value> |
transforms to Option.none() |
undefined |
ParseError |
null |
transforms to Option.none() |
i: I |
transforms to Option.some(a: A) |
Encoding
| Input | Output |
|---|---|
Option.none() |
transforms to <missing value> |
Option.some(a: A) |
transforms back to i: I |
Example (Using Exactness and Handling Null as Missing Value with Optional Field as Option)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option", exact: true, nullable: true, }),})
// ┌─── { readonly quantity?: string | null; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: Option<number>; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))/*throws:ParseError: (Struct (Encoded side) <-> Struct (Type side))└─ Encoded side transformation failure └─ Struct (Encoded side) └─ ["quantity"] └─ NumberFromString └─ Encoded side transformation failure └─ Expected string, actual undefined*/Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option", exact: true, nullable: true, }),})
// ┌─── typeof Schema.NumberFromString// ▼const from = Product.fields.quantity.fromOptional Fields Primitives
optionalToOptional
The Schema.optionalToOptional API allows you to manage transformations from an optional field in the input to an optional field in the output. This can be useful for controlling both the output type and whether a field is present or absent based on specific criteria.
One common use case for optionalToOptional is handling fields where a specific input value, such as an empty string, should be treated as an absent field in the output.
Syntax
const optionalToOptional = <FA, FI, FR, TA, TI, TR>( from: Schema<FA, FI, FR>, to: Schema<TA, TI, TR>, options: { readonly decode: (o: Option.Option<FA>) => Option.Option<TI>, readonly encode: (o: Option.Option<TI>) => Option.Option<FA> }): PropertySignature<"?:", TA, never, "?:", FI, false, FR | TR>In this function:
- The
fromparameter specifies the input schema, andtospecifies the output schema. - The
decodeandencodefunctions define how the field should be interpreted on both sides:Option.none()as an input argument indicates a missing field in the input.- Returning
Option.none()from either function will omit the field in the output.
Example (Omitting Empty Strings from the Output)
Consider an optional field of type string where empty strings in the input should be removed from the output.
import { Option, Schema } from "effect"
const schema = Schema.Struct({ nonEmpty: Schema.optionalToOptional(Schema.String, Schema.String, { // ┌─── Option<string> // ▼ decode: (maybeString) => { if (Option.isNone(maybeString)) { // If `maybeString` is `None`, the field is absent in the input. // Return Option.none() to omit it in the output. return Option.none() } // Extract the value from the `Some` instance const value = maybeString.value if (value === "") { // Treat empty strings as missing in the output // by returning Option.none(). return Option.none() } // Include non-empty strings in the output. return Option.some(value) }, // In the encoding phase, you can decide to process the field // similarly to the decoding phase or use a different logic. // Here, the logic is left unchanged. // // ┌─── Option<string> // ▼ encode: (maybeString) => maybeString, }),})
// Decoding examples
const decode = Schema.decodeUnknownSync(schema)
console.log(decode({}))// Output: {}console.log(decode({ nonEmpty: "" }))// Output: {}console.log(decode({ nonEmpty: "a non-empty string" }))// Output: { nonEmpty: 'a non-empty string' }
// Encoding examples
const encode = Schema.encodeSync(schema)
console.log(encode({}))// Output: {}console.log(encode({ nonEmpty: "" }))// Output: { nonEmpty: '' }console.log(encode({ nonEmpty: "a non-empty string" }))// Output: { nonEmpty: 'a non-empty string' }You can simplify the decoding logic with Option.filter, which filters out unwanted values in a concise way.
Example (Using Option.filter for Decoding)
import { identity, Option, Schema } from "effect"
const schema = Schema.Struct({ nonEmpty: Schema.optionalToOptional(Schema.String, Schema.String, { decode: Option.filter((s) => s !== ""), encode: identity, }),})optionalToRequired
The Schema.optionalToRequired API lets you transform an optional field into a required one, with custom logic to handle cases when the field is missing in the input.
Syntax
const optionalToRequired = <FA, FI, FR, TA, TI, TR>( from: Schema<FA, FI, FR>, to: Schema<TA, TI, TR>, options: { readonly decode: (o: Option.Option<FA>) => TI, readonly encode: (ti: TI) => Option.Option<FA> }): PropertySignature<":", TA, never, "?:", FI, false, FR | TR>In this function:
fromspecifies the input schema, whiletospecifies the output schema.- The
decodeandencodefunctions define the transformation behavior:- Passing
Option.none()todecodemeans the field is absent in the input. The function can then return a default value for the output. - Returning
Option.none()inencodewill omit the field in the output.
- Passing
Example (Setting null as Default for Missing Field)
This example demonstrates how to use optionalToRequired to provide a null default value when the nullable field is missing in the input. During encoding, fields with a value of null are omitted from the output.
import { Option, Schema } from "effect"
const schema = Schema.Struct({ nullable: Schema.optionalToRequired( // Input schema for an optional string Schema.String, // Output schema allowing null or string Schema.NullOr(Schema.String), { // ┌─── Option<string> // ▼ decode: (maybeString) => { if (Option.isNone(maybeString)) { // If `maybeString` is `None`, the field is absent in the input. // Return `null` as the default value for the output. return null } // Extract the value from the `Some` instance // and use it as the output. return maybeString.value }, // During encoding, treat `null` as an absent field // // ┌─── string | null // ▼ encode: (stringOrNull) => stringOrNull === null ? // Omit the field by returning `None` Option.none() : // Include the field by returning `Some` Option.some(stringOrNull), }, ),})
// Decoding examples
const decode = Schema.decodeUnknownSync(schema)
console.log(decode({}))// Output: { nullable: null }console.log(decode({ nullable: "a value" }))// Output: { nullable: 'a value' }
// Encoding examples
const encode = Schema.encodeSync(schema)
console.log(encode({ nullable: "a value" }))// Output: { nullable: 'a value' }console.log(encode({ nullable: null }))// Output: {}You can streamline the decoding and encoding logic using Option.getOrElse and Option.liftPredicate for concise and readable transformations.
Example (Using Option.getOrElse and Option.liftPredicate)
import { Option, Schema } from "effect"
const schema = Schema.Struct({ nullable: Schema.optionalToRequired(Schema.String, Schema.NullOr(Schema.String), { decode: Option.getOrElse(() => null), encode: Option.liftPredicate((value) => value !== null), }),})requiredToOptional
The requiredToOptional API allows you to transform a required field into an optional one, applying custom logic to determine when the field can be omitted.
Syntax
const requiredToOptional = <FA, FI, FR, TA, TI, TR>( from: Schema<FA, FI, FR>, to: Schema<TA, TI, TR>, options: { readonly decode: (fa: FA) => Option.Option<TI> readonly encode: (o: Option.Option<TI>) => FA }): PropertySignature<"?:", TA, never, ":", FI, false, FR | TR>With decode and encode functions, you control the presence or absence of the field:
Option.none()as an argument indecodemeans the field is missing in the input.Option.none()as a return value fromencodemeans the field will be omitted in the output.
Example (Handling Empty String as Missing Value)
In this example, the name field is required but treated as optional if it is an empty string. During decoding, an empty string in name is considered absent, while encoding ensures a value (using an empty string as a default if name is absent).
import { Option, Schema } from "effect"
const schema = Schema.Struct({ name: Schema.requiredToOptional(Schema.String, Schema.String, { // ┌─── string // ▼ decode: (string) => { // Treat empty string as a missing value if (string === "") { // Omit the field by returning `None` return Option.none() } // Otherwise, return the string as is return Option.some(string) }, // ┌─── Option<string> // ▼ encode: (maybeString) => { // Check if the field is missing if (Option.isNone(maybeString)) { // Provide an empty string as default return "" } // Otherwise, return the string as is return maybeString.value }, }),})
// Decoding examples
const decode = Schema.decodeUnknownSync(schema)
console.log(decode({ name: "John" }))// Output: { name: 'John' }console.log(decode({ name: "" }))// Output: {}
// Encoding examples
const encode = Schema.encodeSync(schema)
console.log(encode({ name: "John" }))// Output: { name: 'John' }console.log(encode({}))// Output: { name: '' }You can streamline the decoding and encoding logic using Option.liftPredicate and Option.getOrElse for concise and readable transformations.
Example (Using Option.liftPredicate and Option.getOrElse)
import { Option, Schema } from "effect"
const schema = Schema.Struct({ name: Schema.requiredToOptional(Schema.String, Schema.String, { decode: Option.liftPredicate((s) => s !== ""), encode: Option.getOrElse(() => ""), }),})Extending Schemas
Schemas in effect can be extended in multiple ways, allowing you to combine or enhance existing types with additional fields or functionality. One common method is to use the fields property available in Struct schemas. This property provides a convenient way to add fields or merge fields from different structs while retaining the original Struct type. This approach also makes it easier to access and modify fields.
For more complex cases, such as extending a struct with a union, you may want to use the Schema.extend function, which offers flexibility in scenarios where direct field spreading may not be sufficient.
Spreading Struct fields
Structs provide access to their fields through the fields property, which allows you to extend an existing struct by adding additional fields or combining fields from multiple structs.
Example (Adding New Fields)
import { Schema } from "effect"
const Original = Schema.Struct({ a: Schema.String, b: Schema.String,})
const Extended = Schema.Struct({ ...Original.fields, // Adding new fields c: Schema.String, d: Schema.String,})
// ┌─── {// | readonly a: string;// | readonly b: string;// | readonly c: string;// | readonly d: string;// | }// ▼type Type = typeof Extended.TypeExample (Adding Additional Index Signatures)
import { Schema } from "effect"
const Original = Schema.Struct({ a: Schema.String, b: Schema.String,})
const Extended = Schema.Struct( Original.fields, // Adding an index signature Schema.Record({ key: Schema.String, value: Schema.String }),)
// ┌─── {// │ readonly [x: string]: string;// | readonly a: string;// | readonly b: string;// | }// ▼type Type = typeof Extended.TypeExample (Combining Fields from Multiple Structs)
import { Schema } from "effect"
const Struct1 = Schema.Struct({ a: Schema.String, b: Schema.String,})
const Struct2 = Schema.Struct({ c: Schema.String, d: Schema.String,})
const Extended = Schema.Struct({ ...Struct1.fields, ...Struct2.fields,})
// ┌─── {// | readonly a: string;// | readonly b: string;// | readonly c: string;// | readonly d: string;// | }// ▼type Type = typeof Extended.TypeThe extend function
The Schema.extend function provides a structured method to expand schemas, especially useful when direct field spreading isn’t sufficient—such as when you need to extend a struct with a union of other structs.
Supported extensions include:
Schema.Stringwith anotherSchema.Stringrefinement or a string literalSchema.Numberwith anotherSchema.Numberrefinement or a number literalSchema.Booleanwith anotherSchema.Booleanrefinement or a boolean literal- A struct with another struct where overlapping fields support extension
- A struct with in index signature
- A struct with a union of supported schemas
- A refinement of a struct with a supported schema
- A
suspendof a struct with a supported schema - A transformation between structs where the “from” and “to” sides have no overlapping fields with the target struct
Example (Extending a Struct with a Union of Structs)
import { Schema } from "effect"
const Struct = Schema.Struct({ a: Schema.String,})
const UnionOfStructs = Schema.Union( Schema.Struct({ b: Schema.String }), Schema.Struct({ c: Schema.String }),)
const Extended = Schema.extend(Struct, UnionOfStructs)
// ┌─── {// | readonly a: string;// | } & ({// | readonly b: string;// | } | {// | readonly c: string;// | })// ▼type Type = typeof Extended.TypeExample (Attempting to Extend Structs with Conflicting Fields)
This example demonstrates an attempt to extend a struct with another struct that contains overlapping field names, resulting in an error due to conflicting types.
import { Schema } from "effect"
const Struct = Schema.Struct({ a: Schema.String,})
const OverlappingUnion = Schema.Union( Schema.Struct({ a: Schema.Number }), // conflicting type for key "a" Schema.Struct({ d: Schema.String }),)
const Extended = Schema.extend(Struct, OverlappingUnion)/*throws:Error: Unsupported schema or overlapping typesat path: ["a"]details: cannot extend string with number*/Example (Extending a Refinement with Another Refinement)
In this example, we extend two refinements, Integer and Positive, creating a schema that enforces both integer and positivity constraints.
import { Schema } from "effect"
const Integer = Schema.Int.pipe(Schema.brand("Int"))const Positive = Schema.Positive.pipe(Schema.brand("Positive"))
// ┌─── Schema<number & Brand<"Positive"> & Brand<"Int">, number, never>// ▼const PositiveInteger = Schema.asSchema(Schema.extend(Positive, Integer))
Schema.decodeUnknownSync(PositiveInteger)(-1)/*throwsParseError: positive & Brand<"Positive"> & int & Brand<"Int">└─ From side refinement failure └─ positive & Brand<"Positive"> └─ Predicate refinement failure └─ Expected a positive number, actual -1*/
Schema.decodeUnknownSync(PositiveInteger)(1.1)/*throwsParseError: positive & Brand<"Positive"> & int & Brand<"Int">└─ Predicate refinement failure └─ Expected an integer, actual 1.1*/Renaming Properties
Renaming a Property During Definition
To rename a property directly during schema creation, you can utilize the Schema.fromKey function.
Example (Renaming a Required Property)
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.propertySignature(Schema.String).pipe(Schema.fromKey("c")), b: Schema.Number,})
// ┌─── { readonly c: string; readonly b: number; }// ▼type Encoded = typeof schema.Encoded
// ┌─── { readonly a: string; readonly b: number; }// ▼type Type = typeof schema.Type
console.log(Schema.decodeUnknownSync(schema)({ c: "c", b: 1 }))// Output: { a: "c", b: 1 }Example (Renaming an Optional Property)
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.optional(Schema.String).pipe(Schema.fromKey("c")), b: Schema.Number,})
// ┌─── { readonly b: number; readonly c?: string | undefined; }// ▼type Encoded = typeof schema.Encoded
// ┌─── { readonly a?: string | undefined; readonly b: number; }// ▼type Type = typeof schema.Type
console.log(Schema.decodeUnknownSync(schema)({ c: "c", b: 1 }))// Output: { a: 'c', b: 1 }
console.log(Schema.decodeUnknownSync(schema)({ b: 1 }))// Output: { b: 1 }Using Schema.optional automatically returns a PropertySignature, making it unnecessary to explicitly use Schema.propertySignature as required for renaming required fields in the previous example.
Renaming Properties of an Existing Schema
For existing schemas, the Schema.rename API offers a way to systematically change property names across a schema, even within complex structures like unions, though in case of structs you lose the original field types.
Example (Renaming Properties in a Struct Schema)
import { Schema } from "effect"
const Original = Schema.Struct({ c: Schema.String, b: Schema.Number,})
// Renaming the "c" property to "a"////// ┌─── SchemaClass<{// | readonly a: string;// | readonly b: number;// | }>// ▼const Renamed = Schema.rename(Original, { c: "a" })
console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", b: 1 }))// Output: { a: "c", b: 1 }Example (Renaming Properties in Union Schemas)
import { Schema } from "effect"
const Original = Schema.Union( Schema.Struct({ c: Schema.String, b: Schema.Number, }), Schema.Struct({ c: Schema.String, d: Schema.Boolean, }),)
// Renaming the "c" property to "a" for all members//// ┌─── SchemaClass<{// | readonly a: string;// | readonly b: number;// | } | {// | readonly a: string;// | readonly d: number;// | }>// ▼const Renamed = Schema.rename(Original, { c: "a" })
console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", b: 1 }))// Output: { a: "c", b: 1 }
console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", d: false }))// Output: { a: 'c', d: false }Recursive Schemas
The Schema.suspend function is designed for defining schemas that reference themselves, such as in recursive data structures.
Example (Self-Referencing Schema)
In this example, the Category schema references itself through the subcategories field, which is an array of Category objects.
import { Schema } from "effect"
interface Category { readonly name: string readonly subcategories: ReadonlyArray<Category>}
const Category = Schema.Struct({ name: Schema.String, subcategories: Schema.Array(Schema.suspend((): Schema.Schema<Category> => Category)),})Example (Type Inference Error)
import { Schema } from "effect"
// @errors: 7022const Category = Schema.Struct({ name: Schema.String, // @errors: 7022 7024 subcategories: Schema.Array(Schema.suspend(() => Category)),})A Helpful Pattern to Simplify Schema Definition
As we’ve observed, it’s necessary to define an interface for the Type 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 Fields)
import { Schema } from "effect"
const fields = { name: Schema.String, // ...other fields as needed}
// Define an interface for the Category schema,// extending the Type of the defined fieldsinterface Category extends Schema.Struct.Type<typeof fields> { // Define `subcategories` using recursion readonly subcategories: ReadonlyArray<Category>}
const Category = Schema.Struct({ ...fields, // Spread in the base fields subcategories: Schema.Array( // Define `subcategories` using recursion Schema.suspend((): Schema.Schema<Category> => Category), ),})Mutually Recursive Schemas
You can also use Schema.suspend to create mutually recursive schemas, where two schemas reference each other. In the following example, Expression and Operation form a simple arithmetic expression tree by referencing each other.
Example (Defining Mutually Recursive Schemas)
import { Schema } from "effect"
interface Expression { readonly type: "expression" readonly value: number | Operation}
interface Operation { readonly type: "operation" readonly operator: "+" | "-" readonly left: Expression readonly right: Expression}
const Expression = Schema.Struct({ type: Schema.Literal("expression"), value: Schema.Union( Schema.Number, Schema.suspend((): Schema.Schema<Operation> => Operation), ),})
const Operation = Schema.Struct({ type: Schema.Literal("operation"), operator: Schema.Literal("+", "-"), left: Expression, right: Expression,})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 two interfaces: one for the Type type, as seen previously, and another for the Encoded type.
Example (Recursive Schema with Different Encoded and Type Definitions)
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"
const fields = { id: Schema.NumberFromString, name: Schema.String,}
interface Category extends Schema.Struct.Type<typeof fields> { readonly subcategories: ReadonlyArray<Category>}
const Category = Schema.Struct({ ...fields, subcategories: Schema.Array( // @errors: 2322 Schema.suspend((): Schema.Schema<Category> => Category), ),})This error occurs because the explicit annotation Schema.Schema<Category> is no longer sufficient and needs to be adjusted by explicitly adding the Encoded type:
import { Schema } from "effect"
const fields = { id: Schema.NumberFromString, name: Schema.String,}
interface Category extends Schema.Struct.Type<typeof fields> { readonly subcategories: ReadonlyArray<Category>}
interface CategoryEncoded extends Schema.Struct.Encoded<typeof fields> { readonly subcategories: ReadonlyArray<CategoryEncoded>}
const Category = Schema.Struct({ ...fields, subcategories: Schema.Array( Schema.suspend((): Schema.Schema<Category, CategoryEncoded> => Category), ),})