Getting Started
You can import the necessary types and functions from the effect/Schema module:
Example (Namespace Import)
import * as Schema from "effect/Schema"Example (Named Import)
import { Schema } from "effect"Defining a schema
One common way to define a Schema is by utilizing the Struct constructor.
This constructor allows you to create a new schema that outlines an object with specific properties.
Each property in the object is defined by its own schema, which specifies the data type and any validation rules.
Example (Defining a Simple Object Schema)
This Person schema describes an object with a name (string) and age (number) property:
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite,})Extracting Inferred Types
Type
Once you’ve defined a schema, you can extract its inferred decoded type T in two ways:
- Using the
Schema.Schema.Typeutility - Accessing the
Typefield directly on the schema
Example (Extracting Inferred Type)
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite,})
// 1. Using the Schema.Schema.Type utilitytype Person = Schema.Schema.Type<typeof Person>
// 2. Accessing the Type field directlytype Person2 = typeof Person.TypeThe resulting type will look like this:
type Person = { readonly name: string readonly age: number}Alternatively, you can extract the Person type using the interface keyword, which may improve readability and performance in some cases.
Example (Extracting Type with an Interface)
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite,})
interface Person extends Schema.Schema.Type<typeof Person> {}Both approaches yield the same result, but using an interface provides benefits such as performance advantages and improved readability.
Encoded
For a schema viewed as a Codec<T, E, RD, RE>, the encoded type E can differ from the decoded type T. You can extract the encoded type in two ways:
- Using the
Schema.Codec.Encodedutility - Accessing the
Encodedfield directly on the schema
Example (Extracting the Encoded Type)
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, // a schema that decodes a string to a number age: Schema.FiniteFromString,})
// 1. Using the Schema.Codec.Encoded utilitytype PersonEncoded = Schema.Codec.Encoded<typeof Person>
// 2. Accessing the Encoded field directlytype PersonEncoded2 = typeof Person.EncodedThe resulting type is:
type PersonEncoded = { readonly name: string readonly age: string}Note that age is of type string in the Encoded type of the schema and is of type number in the Type type of the schema.
Alternatively, you can define the PersonEncoded type using the interface keyword, which can enhance readability and performance.
Example (Extracting Encoded Type with an Interface)
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, // a schema that decodes a string to a number age: Schema.FiniteFromString,})
interface PersonEncoded extends Schema.Codec.Encoded<typeof Person> {}Both approaches yield the same result, but using an interface provides benefits such as performance advantages and improved readability.
Services
A Codec<T, E, RD, RE> tracks its service requirements separately in each direction: RD contains the services required for decoding, while RE contains those required for encoding. You can extract both types in two ways:
- Using the
Schema.Codec.DecodingServicesandSchema.Codec.EncodingServicesutilities. - Accessing the
DecodingServicesandEncodingServicesfields directly on the schema.
Example (Extracting the Service Requirements)
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite,})
// 1. Using the Schema.Codec.DecodingServices / EncodingServices utilitiestype PersonDecodingServices = Schema.Codec.DecodingServices<typeof Person>type PersonEncodingServices = Schema.Codec.EncodingServices<typeof Person>
// 2. Accessing the DecodingServices / EncodingServices field directlytype PersonDecodingServices2 = typeof Person.DecodingServicestype PersonEncodingServices2 = typeof Person.EncodingServicesReadonly Types by Default
It’s important to note that by default, most constructors exported by
effect/Schema return readonly types.
Example (Readonly Types in a Schema)
For instance, in the Person schema below:
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite,})the resulting inferred Type would be:
{ readonly name: string; readonly age: number;}Decoding
When working with unknown data types in TypeScript, decoding them into a known structure can be challenging. Luckily, effect/Schema provides several functions to help with this process. Let’s explore how to decode unknown values using these functions.
| API | Description |
|---|---|
decodeUnknownSync |
Synchronously decodes a value and throws an error if parsing fails. |
decodeUnknownExit |
Decodes a value and returns an Exit. |
decodeUnknownOption |
Decodes a value and returns an Option type. |
decodeUnknownResult |
Decodes a value and returns a Result type. |
decodeUnknownPromise |
Decodes a value and returns a Promise. |
decodeUnknownEffect |
Decodes a value and returns an Effect. |
decodeUnknownSync
The Schema.decodeUnknownSync function is useful when you want to parse a value and immediately throw an error if the parsing fails.
Example (Using decodeUnknownSync for Immediate Decoding)
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite,})
// Simulate an unknown inputconst input: unknown = { name: "Alice", age: 30 }
// Example of valid input matching the schemaconsole.log(Schema.decodeUnknownSync(Person)(input))// Output: { name: 'Alice', age: 30 }
// Example of invalid input that does not match the schemaconsole.log(Schema.decodeUnknownSync(Person)(null))/*throws:SchemaError: Expected object*/decodeUnknownResult
The Schema.decodeUnknownResult function allows you to parse a value and receive the result as a Result, representing success (Success) or failure (Failure). This approach lets you handle parsing errors more gracefully without throwing exceptions.
Example (Using Schema.decodeUnknownResult for Error Handling)
import { Result, Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite,})
const decode = Schema.decodeUnknownResult(Person)
// Simulate an unknown inputconst input: unknown = { name: "Alice", age: 30 }
// Attempt decoding a valid inputconst result1 = decode(input) // => Result.succeed({ name: "Alice", age: 30 })if (Result.isSuccess(result1)) { console.log(result1.success) // Output: { name: 'Alice', age: 30 }}
// Simulate decoding an invalid inputconst result2 = decode(null)if (Result.isFailure(result2)) { console.log(result2.failure.message) // Output: Expected object}decodeUnknownEffect
If a schema contains asynchronous transformations, the Sync, Option, Result, and Exit interpreters cannot execute them. Use Schema.decodeUnknownEffect or Schema.decodeUnknownPromise instead.
Example (Handling Asynchronous Decoding)
import { Effect, Schema, SchemaGetter } from "effect"
const PersonId = Schema.Finite
const Person = Schema.Struct({ id: PersonId, name: Schema.String, age: Schema.Finite,})
const asyncSchema = PersonId.pipe( Schema.decodeTo(Person, { // Decode with simulated async transformation decode: SchemaGetter.transformOrFail((id) => Effect.succeed({ id, name: "name", age: 18 }).pipe( Effect.delay("10 millis"), ), ), encode: SchemaGetter.transformOrFail((person) => Effect.succeed(person.id).pipe(Effect.delay("10 millis")), ), }),)
// Attempting to use a synchronous decoder on an async schemaconsole.log(Schema.decodeUnknownExit(asyncSchema)(1))/*Output:{ _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', failures: [ [Object] ] }}*/
// Decoding asynchronously with `Schema.decodeUnknownEffect`Effect.runPromise(Schema.decodeUnknownEffect(asyncSchema)(1)).then(console.log)/*Output:{ id: 1, name: 'name', age: 18 }*/In the code above, the first approach using Schema.decodeUnknownExit results in an error indicating that the transformation cannot be resolved synchronously.
This occurs because Schema.decodeUnknownExit is not designed for async operations.
The second approach, which uses Schema.decodeUnknownEffect, works correctly, allowing you to handle asynchronous transformations and return the expected result.
Encoding
The Schema module provides several encode* functions to encode data according to a schema:
| API | Description |
|---|---|
encodeSync |
Synchronously encodes data and throws an error if encoding fails. |
encodeExit |
Encodes data and returns an Exit. |
encodeOption |
Encodes data and returns an Option type. |
encodeResult |
Encodes data and returns a Result type representing success or failure. |
encodePromise |
Encodes data and returns a Promise. |
encodeEffect |
Encodes data and returns an Effect. |
Example (Using Schema.encodeSync for Immediate Encoding)
import { Schema } from "effect"
const Person = Schema.Struct({ // Ensure name is a non-empty string name: Schema.NonEmptyString, // Allow age to be decoded from a string and encoded to a string age: Schema.FiniteFromString,})
// Valid input: encoding succeeds and returns expected typesconsole.log(Schema.encodeSync(Person)({ name: "Alice", age: 30 }))// Output: { name: 'Alice', age: '30' }
// Invalid input: encoding fails due to empty name stringconsole.log(Schema.encodeSync(Person)({ name: "", age: 30 }))/*throws:SchemaError: Expected a value with a length of at least 1 at ["name"]*/Note that during encoding, the number value 30 was converted to a string "30".
SchemaError
The Schema.decodeUnknownResult and Schema.encodeResult functions return a Result, with different success types for each direction:
decodeUnknownResult: (input: unknown) => Result<T, SchemaError>encodeResult: (input: T) => Result<E, SchemaError>where SchemaError is defined as follows (simplified):
interface SchemaError { readonly _tag: "SchemaError" readonly issue: SchemaIssue.Issue}In this structure, SchemaIssue.Issue represents an error that might occur during decoding or encoding.
It is wrapped in a tagged error to make it easier to catch errors using Effect.catchTag.
Decoding succeeds with the decoded type T, while encoding succeeds with the encoded type E. In either direction, a schema mismatch produces a Failure containing a SchemaError.
Parse Options
The options below provide control over both decoding and encoding behaviors.
Managing Excess Properties
By default, any properties not defined in the schema are removed from the output when parsing a value. This ensures the parsed data conforms strictly to the expected structure.
If you want to detect and handle unexpected properties, use the onExcessProperty option (default value: "ignore"), which allows you to raise an error for excess properties. This can be helpful when you need to validate and catch unanticipated properties.
Example (Setting onExcessProperty to "error")
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite,})
// Excess properties are ignored by defaultconsole.log( Schema.decodeUnknownSync(Person)({ name: "Bob", age: 40, email: "bob@example.com", // Ignored }),)/*Output:{ name: 'Bob', age: 40 }*/
// With `onExcessProperty` set to "error",// an error is thrown for excess propertiesSchema.decodeUnknownSync(Person)( { name: "Bob", age: 40, email: "bob@example.com", // Will raise an error }, { onExcessProperty: "error" },)/*throwsSchemaError: Expected no excess property at ["email"]*/To retain extra properties, set onExcessProperty to "preserve".
Example (Setting onExcessProperty to "preserve")
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite,})
// Excess properties are preserved in the outputSchema.decodeUnknownSync(Person)( { name: "Bob", age: 40, email: "bob@example.com", }, { onExcessProperty: "preserve" },) // => { email: "bob@example.com", name: "Bob", age: 40 }Receiving All Errors
The errors option enables you to retrieve all errors encountered during parsing. By default, only the first error is returned. Setting errors to "all" provides comprehensive error feedback, which can be useful for debugging or offering detailed validation feedback.
Example (Setting errors to "all")
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite,})
// Attempt to parse with multiple issues in the input dataSchema.decodeUnknownSync(Person)( { name: "Bob", age: "abc", email: "bob@example.com", }, { errors: "all", onExcessProperty: "error" },)/*throwsSchemaError: Expected no excess property at ["email"]Expected number at ["age"]*/Managing Property Order
The propertyOrder option provides control over the order of object fields in the output. This feature is particularly useful when the sequence of keys is important for the consuming processes or when maintaining the input order enhances readability and usability.
By default, the propertyOrder option is set to "none". This means that the internal system decides the order of keys to optimize parsing speed.
The order of keys in this mode should not be considered stable, and it’s recommended not to rely on key ordering as it may change in future updates.
Setting propertyOrder to "original" ensures that the keys are ordered as they appear in the input during the decoding/encoding process.
Example (Synchronous Decoding)
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.Finite, b: Schema.Literal("b"), c: Schema.Finite,})
// Default decoding, where property order is system-definedSchema.decodeUnknownSync(schema)({ b: "b", c: 2, a: 1 }) // => { a: 1, b: "b", c: 2 }
// Decoding while preserving input orderSchema.decodeUnknownSync(schema)( { b: "b", c: 2, a: 1 }, { propertyOrder: "original" },) // => { b: "b", c: 2, a: 1 }Example (Asynchronous Decoding)
import type { Duration } from "effect"import { Effect, Schema, SchemaGetter } from "effect"
// Helper function to simulate an async operation in schemaconst effectify = (duration: Duration.Input) => Schema.Finite.pipe( Schema.decodeTo(Schema.Finite, { decode: SchemaGetter.transformOrFail((x) => Effect.sleep(duration).pipe(Effect.andThen(Effect.succeed(x))), ), encode: SchemaGetter.passthrough(), }), )
// Define a structure with asynchronous behavior in each fieldconst schema = Schema.Struct({ a: effectify("200 millis"), b: effectify("300 millis"), c: effectify("100 millis"),})
// Default decoding, where property order is system-definedSchema.decodeEffect(schema)({ a: 1, b: 2, c: 3 }, { concurrency: 3 }) .pipe(Effect.runPromise) .then(console.log)// Output decided internally: { a: 1, b: 2, c: 3 }
// Decoding while preserving input orderSchema.decodeEffect(schema)( { a: 1, b: 2, c: 3 }, { concurrency: 3, propertyOrder: "original" },) .pipe(Effect.runPromise) .then(console.log)// Output preserving input order: { a: 1, b: 2, c: 3 }Customizing Parsing Behavior at the Schema Level
The parseOptions annotation allows you to customize parsing behavior at different schema levels, enabling you to apply unique parsing settings to nested schemas within a structure. Options defined within a schema override parent-level settings and apply to all nested schemas.
Example (Using parseOptions to Customize Error Handling)
import { Result, Schema } from "effect"
const schema = Schema.Struct({ a: Schema.Struct({ b: Schema.String, c: Schema.String, }).annotate({ title: "first error only", // Limit errors to the first in this sub-schema parseOptions: { errors: "first" }, }), d: Schema.String,}).annotate({ title: "all errors", // Capture all errors for the main schema parseOptions: { errors: "all" },})
// Decode input with custom error-handling behaviorconst result = Schema.decodeUnknownResult(schema)( { a: {} }, { errors: "first" },)if (Result.isFailure(result)) { console.log(result.failure.message) result.failure.message // => 'Missing key\n at ["a"]["b"]\nMissing key\n at ["d"]'}Detailed Output Explanation:
In this example:
- The main schema is configured to display all errors. Hence, you will see errors related to both the
dfield (since it’s missing) and any errors from theasubschema. - The subschema (
a) is set to display only the first error. Although bothbandcfields are missing, only the first missing field (b) is reported.
Type Guards
The Schema.is function provides a way to verify if a value conforms to a given schema. It acts as a type guard, taking a value of type unknown and determining if it matches the structure and type constraints defined in the schema.
Here’s how the Schema.is function works:
-
Schema Definition: Define a schema to describe the structure and constraints of the data type you expect. Its decoded type
Tis the target type checked by the type guard. -
Type Guard Creation: Use the schema to create a user-defined type guard,
(input: unknown) => input is T. This function can be used at runtime to check if a value meets the requirements of the schema.
Example (Creating and Using a Type Guard)
import { Schema } from "effect"
// Define a schema for a Person objectconst Person = Schema.Struct({ name: Schema.String, age: Schema.Finite,})
// Generate a type guard from the schemaconst isPerson = Schema.is(Person)
// Test the type guard with various inputsisPerson({ name: "Alice", age: 30 }) // => true
isPerson(null) // => false
isPerson({}) // => falseThe generated isPerson function has the following signature:
const isPerson: <Input>(input: Input) => input is Input & { readonly name: string readonly age: number}Assertions
While type guards verify whether a value conforms to a specific type, the Schema.asserts function goes further by asserting that an input matches the decoded type T described by the schema.
If the input does not match the schema, it throws a detailed error, making it useful for runtime validation.
Example (Creating and Using an Assertion)
import { Schema } from "effect"
// Define a schema for a Person objectconst Person = Schema.Struct({ name: Schema.String, age: Schema.Finite,})
// Define an assertion wrapper for the schemaconst assertsPerson: (input: unknown) => asserts input is { readonly name: string readonly age: number} = (input) => Schema.asserts(Person, input)
try { // Attempt to assert that the input matches the Person schema assertsPerson({ name: "Alice", age: "30" })} catch (e: any) { console.error("The input does not match the schema:") console.error(e.message) e.message // => 'Expected number\n at ["age"]'}
// This input matches the schema and will not throw an errorassertsPerson({ name: "Alice", age: 30 })The assertsPerson wrapper has the following signature:
const assertsPerson: (input: unknown) => asserts input is { readonly name: string readonly age: number}Naming Conventions
Schema names describe the decoded type and, when a transformation is involved, the encoded representation it is decoded from.
Schemas whose decoded and encoded types are the same are generally named after that type:
Schema.Finitedescribes finite numbers in both directions.Schema.DatedescribesDatevalues in both directions.
For transformed schemas, a name of the form TFromE reads as “decode E into T”:
Schema.FiniteFromStringdecodes astringinto a finitenumberand encodes the number back into astring.Schema.DateFromStringdecodes an ISO-formattedstringinto aDateand encodes theDateback into astring.