Introduction
Welcome to the documentation for @effect/schema
, a library for defining and using schemas to validate and transform data in TypeScript.
@effect/schema
allows you to define a Schema<Type, Encoded, Context>
that provides a blueprint for describing the structure and data types of your data. Once defined, you can leverage this schema to perform a range of operations, including:
Operation | Description |
---|
Decoding | Transforming data from an input type Encoded to an output type Type . |
Encoding | Converting data from an output type Type back to an input type Encoded . |
Asserting | Verifying that a value adheres to the schema's output type Type . |
Arbitraries | Generate arbitraries for fast-check testing. |
Pretty printing | Support pretty printing for data structures. |
JSON Schemas | Create JSON Schemas based on defined schemas. |
Equivalence | Create Equivalences based on defined schemas. |
If you're eager to learn how to define your first schema, jump straight to the Basic usage section!
The Schema Type
The Schema<Type, Encoded, Context>
type represents an immutable value that describes the structure of your data.
The Schema
type has three type parameters with the following meanings:
- Type. Represents the type of value that a schema can succeed with during decoding.
- Encoded. Represents the type of value that a schema can succeed with during encoding. By default, it's equal to
Type
if not explicitly provided. - Context. Similar to the
Effect
type, it represents the contextual data required by the schema to execute both decoding and encoding. If this type parameter is never
(default if not explicitly provided), it means the schema has no requirements.
Examples
Schema<string>
(defaulted to Schema<string, string, never>
) represents a schema that decodes to string
, encodes to string
, and has no requirements.Schema<number, string>
(defaulted to Schema<number, string, never>
) represents a schema that decodes to number
from string
, encodes a number
to a string
, and has no requirements.
[!NOTE]
In the Effect ecosystem, you may often encounter the type parameters of Schema
abbreviated as A
, I
, and R
respectively. This is just shorthand for the type value of type A, Input, and Requirements.
Schema
values are immutable, and all @effect/schema
functions produce new Schema
values.
Schema
values do not actually do anything, they are just values that model or describe the structure of your data.
Schema
values don't perform any actions themselves; they simply describe the structure of your data. A Schema
can be interpreted by various "compilers" into specific operations, depending on the compiler type (decoding, encoding, pretty printing, arbitraries, etc.).
Understanding Decoding and Encoding
sequenceDiagram
participant UA as unknown
participant A
participant I
participant UI as unknown
UI->>A: decodeUnknown
I->>A: decode
A->>I: encode
UA->>I: encodeUnknown
UA->>A: validate
UA->>A: is
UA->>A: asserts
We'll break down these concepts using an example with a Schema<Date, string, never>
. This schema serves as a tool to transform a string
into a Date
and vice versa.
Encoding
When we talk about "encoding," we are referring to the process of changing a Date
into a string
. To put it simply, it's the act of converting data from one format to another.
Decoding
Conversely, "decoding" entails transforming a string
back into a Date
. It's essentially the reverse operation of encoding, where data is returned to its original form.
Decoding From Unknown
Decoding from unknown
involves two key steps:
-
Checking: Initially, we verify that the input data (which is of the unknown
type) matches the expected structure. In our specific case, this means ensuring that the input is indeed a string
.
-
Decoding: Following the successful check, we proceed to convert the string
into a Date
. This process completes the decoding operation, where the data is both validated and transformed.
Encoding From Unknown
Encoding from unknown
involves two key steps:
-
Checking: Initially, we verify that the input data (which is of the unknown
type) matches the expected structure. In our specific case, this means ensuring that the input is indeed a Date
.
-
Encoding: Following the successful check, we proceed to convert the Date
into a string
. This process completes the encoding operation, where the data is both validated and transformed.
[!NOTE]
As a general rule, schemas should be defined such that encode + decode return the original value.
Recap
- Decoding: Used for parsing data from external sources where you have no control over the data format.
- Encoding: Used when sending data out to external sources, converting it to a format that is expected by those sources.
For instance, when working with forms in the frontend, you often receive untyped data in the form of strings. This data can be tampered with and does not natively support arrays or booleans. Decoding helps you validate and parse this data into more useful types like numbers, dates, and arrays. Encoding allows you to convert these types back into the string format expected by forms.
By understanding these processes, you can ensure that your data handling is robust and reliable, converting data safely between different formats.
The Rule of Schemas: Keeping Encode and Decode in Sync
When working with schemas, there's an important rule to keep in mind: your schemas should be crafted in a way that when you perform both encoding and decoding operations, you should end up with the original value.
In simpler terms, if you encode a value and then immediately decode it, the result should match the original value you started with. This rule ensures that your data remains consistent and reliable throughout the encoding and decoding process.
Requirements
Understanding exactOptionalPropertyTypes
The @effect/schema
library takes advantage of the exactOptionalPropertyTypes
option of tsconfig.json
. This option affects how optional properties are typed (to learn more about this option, you can refer to the official TypeScript documentation).
Let's delve into this with an example.
With exactOptionalPropertyTypes
Enabled
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.optionalWith(Schema.String.pipe(Schema.nonEmptyString()), {
exact: true
})
})
type Type = Schema.Schema.Type<typeof Person>
Schema.decodeSync(Person)({ name: undefined })
Here, notice that the type of name
is "exact" (string
), which means the type checker will catch any attempt to assign an invalid value (like undefined
).
With exactOptionalPropertyTypes
Disabled
If, for some reason, you can't enable the exactOptionalPropertyTypes
option (perhaps due to conflicts with other third-party libraries), you can still use @effect/schema
. However, there will be a mismatch between the types and the runtime behavior:
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.optionalWith(Schema.String.pipe(Schema.nonEmptyString()), {
exact: true
})
})
type Type = Schema.Schema.Type<typeof Person>
Schema.decodeSync(Person)({ name: undefined })
In this case, the type of name
is widened to string | undefined
, which means the type checker won't catch the invalid value (undefined
). However, during decoding, you'll encounter an error, indicating that undefined
is not allowed.
Getting started
To install the alpha version:
npm install @effect/schema
Additionally, make sure to install the following packages, as they are peer dependencies. Note that some package managers might not install peer dependencies by default, so you need to install them manually:
effect
package (peer dependency)
[!WARNING]
This package is primarily published to receive early feedback and for contributors, during this development phase we cannot guarantee the stability of the APIs, consider each minor release to contain breaking changes.
Once you have installed the library, you can import the necessary types and functions from the @effect/schema/Schema
module.
Example (Namespace Import)
import * as Schema from "@effect/schema/Schema"
Example (Named Import)
import { Schema } from "@effect/schema"
Defining a schema
One common way to define a Schema
is by utilizing the struct
constructor provided by @effect/schema
. This function 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.
For example, consider the following Schema
that describes a person object with a name
property of type string
and an age
property of type number
:
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
[!NOTE]
It's important to note that by default, most constructors exported by @effect/schema
return readonly
types. For instance, in the Person
schema above, the resulting type would be { readonly name: string; readonly age: number; }
.
Type
Once you've defined a Schema<A, I, R>
, you can extract the inferred type A
, which represents the data described by the schema, in two ways:
- Using the
Schema.Schema.Type
utility. - Using the
Type
field defined on your schema.
For example, you can extract the inferred type of a Person
object as demonstrated below:
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.NumberFromString
})
type Person = Schema.Schema.Type<typeof Person>
type Person2 = typeof Person.Type
Alternatively, you can define the Person
type using the interface
keyword:
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
In cases where in a Schema<A, I>
the I
type differs from the A
type, you can also extract the inferred I
type using the Schema.Encoded
utility (or the Encoded
field defined on your schema).
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.NumberFromString
})
type PersonEncoded = Schema.Schema.Encoded<typeof Person>
type PersonEncoded2 = typeof Person.Encoded
Context
You can also extract the inferred type R
that represents the context described by the schema using the Schema.Context
utility:
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.NumberFromString
})
type PersonContext = Schema.Schema.Context<typeof Person>
To create a schema with an opaque type, you can use the following technique that re-declares the schema:
import { Schema } from "@effect/schema"
const _Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
interface Person extends Schema.Schema.Type<typeof _Person> {}
const Person: Schema.Schema<Person> = _Person
Alternatively, you can use the Class
APIs (see the Class section below for more details).
Note that the technique shown above becomes more complex when the schema is defined such that A
is different from I
. For example:
import { Schema } from "@effect/schema"
const _Person = Schema.Struct({
name: Schema.String,
age: Schema.NumberFromString
})
interface Person extends Schema.Schema.Type<typeof _Person> {}
interface PersonEncoded extends Schema.Schema.Encoded<typeof _Person> {}
const Person: Schema.Schema<Person, PersonEncoded> = _Person
In this case, the field "age"
is of type string
in the Encoded
type of the schema and is of type number
in the Type
type of the schema. Therefore, we need to define two interfaces (PersonEncoded
and Person
) and use both to redeclare our final schema Person
.
Decoding From Unknown Values
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.
Using decodeUnknown*
Functions
The @effect/schema/Schema
module offers a variety of decodeUnknown*
functions, each tailored for different decoding scenarios:
decodeUnknownSync
: Synchronously decodes a value and throws an error if parsing fails.decodeUnknownOption
: Decodes a value and returns an Option
type.decodeUnknownEither
: Decodes a value and returns an Either
type.decodeUnknownPromise
: Decodes a value and returns a Promise
.decodeUnknown
: Decodes a value and returns an Effect
.
Example (Using decodeUnknownSync
)
Let's begin with an example using the decodeUnknownSync
function. This function is useful when you want to parse a value and immediately throw an error if the parsing fails.
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const input: unknown = { name: "Alice", age: 30 }
console.log(Schema.decodeUnknownSync(Person)(input))
console.log(Schema.decodeUnknownSync(Person)(null))
Example (Using decodeUnknownEither
)
Now, let's see how to use the decodeUnknownEither
function, which returns an Either
type representing success or failure.
import { Schema } from "@effect/schema"
import { Either } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const decode = Schema.decodeUnknownEither(Person)
const input: unknown = { name: "Alice", age: 30 }
const result1 = decode(input)
if (Either.isRight(result1)) {
console.log(result1.right)
}
const result2 = decode(null)
if (Either.isLeft(result2)) {
console.log(result2.left)
}
The decode
function returns an Either<A, ParseError>
, where ParseError
is defined as follows:
interface ParseError {
readonly _tag: "ParseError"
readonly issue: ParseIssue
}
Here, ParseIssue
represents an error that might occur during the parsing process. It is wrapped in a tagged error to make it easier to catch errors using Effect.catchTag
. The result Either<A, ParseError>
contains the inferred data type described by the schema. A successful parse yields a Right
value with the parsed data A
, while a failed parse results in a Left
value containing a ParseError
.
Handling Async Transformations
When your schema involves asynchronous transformations, neither the decodeUnknownSync
nor the decodeUnknownEither
functions will work for you. In such cases, you must turn to the decodeUnknown
function, which returns an Effect
.
import { Schema } from "@effect/schema"
import { Effect } from "effect"
const PersonId = Schema.Number
const Person = Schema.Struct({
id: PersonId,
name: Schema.String,
age: Schema.Number
})
const asyncSchema = Schema.transformOrFail(PersonId, Person, {
strict: true,
decode: (id) =>
Effect.succeed({ id, name: "name", age: 18 }).pipe(
Effect.delay("10 millis")
),
encode: (person) => Effect.succeed(person.id).pipe(Effect.delay("10 millis"))
})
const syncParsePersonId = Schema.decodeUnknownEither(asyncSchema)
console.log(JSON.stringify(syncParsePersonId(1), null, 2))
const asyncParsePersonId = Schema.decodeUnknown(asyncSchema)
Effect.runPromise(asyncParsePersonId(1)).then(console.log)
As shown in the code above, the first approach returns a Forbidden
error, indicating that using decodeUnknownEither
with an async transformation is not allowed. However, the second approach works as expected, allowing you to handle async transformations and return the desired result.
Parse Options
Excess properties
When using a Schema
to parse a value, by default any properties that are not specified in the Schema
will be stripped out from the output. This is because the Schema
is expecting a specific shape for the parsed value, and any excess properties do not conform to that shape.
However, you can use the onExcessProperty
option (default value: "ignore"
) to trigger a parsing error. This can be particularly useful in cases where you need to detect and handle potential errors or unexpected values.
Here's an example of how you might use onExcessProperty
set to "error"
:
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
console.log(
Schema.decodeUnknownSync(Person)({
name: "Bob",
age: 40,
email: "bob@example.com"
})
)
Schema.decodeUnknownSync(Person)(
{
name: "Bob",
age: 40,
email: "bob@example.com"
},
{ onExcessProperty: "error" }
)
If you want to allow excess properties to remain, you can use onExcessProperty
set to "preserve"
:
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
console.log(
Schema.decodeUnknownSync(Person)(
{
name: "Bob",
age: 40,
email: "bob@example.com"
},
{ onExcessProperty: "preserve" }
)
)
[!NOTE]
The onExcessProperty
and error
options also affect encoding.
All errors
The errors
option allows you to receive all parsing errors when attempting to parse a value using a schema. By default only the first error is returned, but by setting the errors
option to "all"
, you can receive all errors that occurred during the parsing process. This can be useful for debugging or for providing more comprehensive error messages to the user.
Here's an example of how you might use errors
:
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
Schema.decodeUnknownSync(Person)(
{
name: "Bob",
age: "abc",
email: "bob@example.com"
},
{ errors: "all", onExcessProperty: "error" }
)
[!NOTE]
The onExcessProperty
and error
options also affect encoding.
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 without notice.
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/schema"
const schema = Schema.Struct({
a: Schema.Number,
b: Schema.Literal("b"),
c: Schema.Number
})
console.log(Schema.decodeUnknownSync(schema)({ b: "b", c: 2, a: 1 }))
console.log(
Schema.decodeUnknownSync(schema)(
{ b: "b", c: 2, a: 1 },
{ propertyOrder: "original" }
)
)
Example (Asynchronous Decoding)
import { ParseResult, Schema } from "@effect/schema"
import type { Duration } from "effect"
import { Effect } from "effect"
const effectify = (duration: Duration.DurationInput) =>
Schema.Number.pipe(
Schema.transformOrFail(Schema.Number, {
strict: true,
decode: (x) =>
Effect.sleep(duration).pipe(Effect.andThen(ParseResult.succeed(x))),
encode: ParseResult.succeed
})
)
const schema = Schema.Struct({
a: effectify("200 millis"),
b: effectify("300 millis"),
c: effectify("100 millis")
}).annotations({ concurrency: 3 })
Schema.decode(schema)({ a: 1, b: 2, c: 3 })
.pipe(Effect.runPromise)
.then(console.log)
Schema.decode(schema)({ a: 1, b: 2, c: 3 }, { propertyOrder: "original" })
.pipe(Effect.runPromise)
.then(console.log)
Customizing Parsing Behavior at the Schema Level
You can tailor parse options for each schema using the parseOptions
annotation. These options allow for specific parsing behavior at various levels of the schema hierarchy, overriding any parent settings and cascading down to nested schemas.
import { Schema } from "@effect/schema"
import { Either } from "effect"
const schema = Schema.Struct({
a: Schema.Struct({
b: Schema.String,
c: Schema.String
}).annotations({
title: "first error only",
parseOptions: { errors: "first" }
}),
d: Schema.String
}).annotations({
title: "all errors",
parseOptions: { errors: "all" }
})
const result = Schema.decodeUnknownEither(schema)(
{ a: {} },
{ errors: "first" }
)
if (Either.isLeft(result)) {
console.log(result.left.message)
}
Detailed Output Explanation:
In this example:
- The main schema is configured to display all errors. Hence, you will see errors related to both the
d
field (since it's missing) and any errors from the a
subschema. - The subschema (
a
) is set to display only the first error. Although both b
and c
fields are missing, only the first missing field (b
) is reported.
Managing Missing Properties
When using the @effect/schema
library to handle data structures, it's important to understand how missing properties are processed. By default, if a property is not present in the input, it is treated as if it were present with an undefined
value.
import { Schema } from "@effect/schema"
const schema = Schema.Struct({ a: Schema.Unknown })
const input = {}
console.log(Schema.decodeUnknownSync(schema)(input))
In this example, although the key "a"
is not present in the input, it is treated as { a: undefined }
by default.
If your validation logic needs to distinguish between truly missing properties and those that are explicitly undefined, you can enable the exact
option:
import { Schema } from "@effect/schema"
const schema = Schema.Struct({ a: Schema.Unknown })
const input = {}
console.log(Schema.decodeUnknownSync(schema)(input, { exact: true }))
For the APIs is
and asserts
, however, the default behavior is to treat missing properties strictly, where the default for exact
is true
:
import type { AST } from "@effect/schema"
import { Schema } from "@effect/schema"
const schema = Schema.Struct({ a: Schema.Unknown })
const input = {}
console.log(Schema.is(schema)(input))
console.log(Schema.is(schema)(input, { exact: false }))
const asserts: (
u: unknown,
overrideOptions?: AST.ParseOptions
) => asserts u is {
readonly a: unknown
} = Schema.asserts(schema)
try {
asserts(input)
console.log("asserts passed")
} catch (e: any) {
console.error("asserts failed")
console.error(e.message)
}
try {
asserts(input, { exact: false })
console.log("asserts passed")
} catch (e: any) {
console.error("asserts failed")
console.error(e.message)
}
Encoding
The @effect/schema/Schema
module provides several encode*
functions to encode data according to a schema:
encodeSync
: Synchronously encodes data and throws an error if encoding fails.encodeOption
: Encodes data and returns an Option
type.encodeEither
: Encodes data and returns an Either
type representing success or failure.encodePromise
: Encodes data and returns a Promise
.encode
: Encodes data and returns an Effect
.
Let's consider an example where we have a schema for a Person
object with a name
property of type string
and an age
property of type number
.
import * as S from "@effect/schema/Schema"
import { Schema } from "@effect/schema"
const Age = Schema.NumberFromString
const Person = Schema.Struct({
name: Schema.NonEmptyString,
age: Age
})
console.log(Schema.encodeSync(Person)({ name: "Alice", age: 30 }))
console.log(Schema.encodeSync(Person)({ name: "", age: 30 }))
Note that during encoding, the number value 30
was converted to a string "30"
.
[!NOTE]
The onExcessProperty
and error
options also affect encoding.
Handling Unsupported Encoding
Although it is generally recommended to define schemas that support both decoding and encoding, there are situations where encoding support might be impossible. In such cases, the Forbidden
error can be used to handle unsupported encoding.
Here is an example of a transformation that never fails during decoding. It returns an Either
containing either the decoded value or the original input. For encoding, it is reasonable to not support it and use Forbidden
as the result.
import { ParseResult, Schema } from "@effect/schema"
import { Either } from "effect"
export const SafeDecode = <A, I>(self: Schema.Schema<A, I, never>) => {
const decodeUnknownEither = Schema.decodeUnknownEither(self)
return Schema.transformOrFail(
Schema.Unknown,
Schema.EitherFromSelf({
left: Schema.Unknown,
right: Schema.typeSchema(self)
}),
{
strict: true,
decode: (input) =>
ParseResult.succeed(
Either.mapLeft(decodeUnknownEither(input), () => input)
),
encode: (actual, _, ast) =>
Either.match(actual, {
onLeft: () =>
ParseResult.fail(
new ParseResult.Forbidden(ast, actual, "cannot encode a Left")
),
onRight: ParseResult.succeed
})
}
)
}
Explanation
-
Decoding: The SafeDecode
function ensures that decoding never fails. It wraps the decoded value in an Either
, where a successful decoding results in a Right
and a failed decoding results in a Left
containing the original input.
-
Encoding: The encoding process uses the Forbidden
error to indicate that encoding a Left
value is not supported. Only Right
values are successfully encoded.
Naming Conventions
The naming conventions in @effect/schema
are designed to be straightforward and logical, focusing primarily on compatibility with JSON serialization. This approach simplifies the understanding and use of schemas, especially for developers who are integrating web technologies where JSON is a standard data interchange format.
Overview of Naming Strategies
JSON-Compatible Types
Schemas that naturally serialize to JSON-compatible formats are named directly after their data types.
For instance:
Schema.Date
: serializes JavaScript Date objects to ISO-formatted strings, a typical method for representing dates in JSON.Schema.Number
: used directly as it maps precisely to the JSON number type, requiring no special transformation to remain JSON-compatible.
Non-JSON-Compatible Types
When dealing with types that do not have a direct representation in JSON, the naming strategy incorporates additional details to indicate the necessary transformation. This helps in setting clear expectations about the schema's behavior:
For instance:
Schema.DateFromSelf
: indicates that the schema handles Date
objects, which are not natively JSON-serializable.Schema.NumberFromString
: this naming suggests that the schema processes numbers that are initially represented as strings, emphasizing the transformation from string to number when decoding.
Practical Application
The primary goal of these schemas is to ensure that domain objects can be easily serialized ("encoded") and deserialized ("decoded") for transmission over network connections, thus facilitating their transfer between different parts of an application or across different applications.
Here is an example demonstrating how straightforward naming conventions can be applied:
import { Schema } from "@effect/schema"
const schema = Schema.Struct({
sym: Schema.Symbol,
optional: Schema.Option(Schema.Date),
chunk: Schema.Chunk(Schema.BigInt),
createdAt: Schema.Date,
updatedAt: Schema.Date
})
Rationale
While JSON's ubiquity justifies its primary consideration in naming, the conventions also accommodate serialization for other types of transport. For instance, converting a Date
to a string is a universally useful method for various communication protocols, not just JSON. Thus, the selected naming conventions serve as sensible defaults that prioritize clarity and ease of use, facilitating the serialization and deserialization processes across diverse technological environments.
Formatting Errors
When you're working with Effect Schema and encounter errors during decoding, or encoding functions, you can format these errors in two different ways: using the TreeFormatter
or the ArrayFormatter
.
TreeFormatter (default)
The TreeFormatter
is the default method for formatting errors. It organizes errors in a tree structure, providing a clear hierarchy of issues.
Here's an example of how it works:
import { Schema, TreeFormatter } from "@effect/schema"
import { Either } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const decode = Schema.decodeUnknownEither(Person)
const result = decode({})
if (Either.isLeft(result)) {
console.error("Decoding failed:")
console.error(TreeFormatter.formatErrorSync(result.left))
}
In this example, the tree error message is structured as follows:
{ readonly name: string; readonly age: number }
represents the schema, providing a visual representation of the expected structure. This can be customized by using annotations like identifier
, title
, or description
.["name"]
points to the problematic property, in this case, the "name"
property.is missing
details the specific error for the "name"
property.
Customizing the Output
The default error message represents the involved schemas in a TypeScript-like syntax:
{ readonly name: string; readonly age: number }
You can customize this output by adding annotations such as identifier
, title
, or description
.
These annotations are applied in this order of priority and allow for a more concise and clear representation in error messages.
import { Schema, TreeFormatter } from "@effect/schema"
import { Either } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
}).annotations({ title: "Person" })
const result = Schema.decodeUnknownEither(Person)({})
if (Either.isLeft(result)) {
console.error(TreeFormatter.formatErrorSync(result.left))
}
In this modified example, by adding a title
annotation, the schema representation in the error message changes to "Person", providing a simpler and more understandable output. This helps in identifying the schema involved more quickly and improves the readability of the error messages.
Handling Multiple Errors
By default, decoding functions like decodeUnknownEither
return only the first encountered error. If you require a comprehensive list of all errors, you can modify the behavior by passing the { errors: "all" }
option:
import { Schema, TreeFormatter } from "@effect/schema"
import { Either } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const decode = Schema.decodeUnknownEither(Person, { errors: "all" })
const result = decode({})
if (Either.isLeft(result)) {
console.error("Decoding failed:")
console.error(TreeFormatter.formatErrorSync(result.left))
}
This adjustment ensures that the formatter displays all errors related to the input, providing a more detailed diagnostic of what went wrong.
ParseIssueTitle Annotation
When a decoding or encoding operation fails, it's useful to have additional details in the default error message returned by TreeFormatter
to understand exactly which value caused the operation to fail. To achieve this, you can set an annotation that depends on the value undergoing the operation and can return an excerpt of it, making it easier to identify the problematic value. A common scenario is when the entity being validated has an id
field. The ParseIssueTitle
annotation facilitates this kind of analysis during error handling.
The type of the annotation is:
export type ParseIssueTitleAnnotation = (
issue: ParseIssue
) => string | undefined
If you set this annotation on a schema and the provided function returns a string
, then that string is used as the title by TreeFormatter
, unless a message
annotation (which has the highest priority) has also been set. If the function returns undefined
, then the default title used by TreeFormatter
is determined with the following priorities:
identifier
title
description
ast.toString()
Example
import type { ParseResult } from "@effect/schema"
import { Schema } from "@effect/schema"
const getOrderItemId = ({ actual }: ParseResult.ParseIssue) => {
if (Schema.is(Schema.Struct({ id: Schema.String }))(actual)) {
return `OrderItem with id: ${actual.id}`
}
}
const OrderItem = Schema.Struct({
id: Schema.String,
name: Schema.String,
price: Schema.Number
}).annotations({
identifier: "OrderItem",
parseIssueTitle: getOrderItemId
})
const getOrderId = ({ actual }: ParseResult.ParseIssue) => {
if (Schema.is(Schema.Struct({ id: Schema.Number }))(actual)) {
return `Order with id: ${actual.id}`
}
}
const Order = Schema.Struct({
id: Schema.Number,
name: Schema.String,
items: Schema.Array(OrderItem)
}).annotations({
identifier: "Order",
parseIssueTitle: getOrderId
})
const decode = Schema.decodeUnknownSync(Order, { errors: "all" })
decode({})
decode({ id: 1 })
decode({ id: 1, items: [{ id: "22b", price: "100" }] })
In the examples above, we can see how the parseIssueTitle
annotation helps provide meaningful error messages when decoding fails.
ArrayFormatter
The ArrayFormatter
offers an alternative method for formatting errors within @effect/schema
, organizing them into a more structured and easily navigable array format. This formatter is especially useful when you need a clear overview of all issues detected during the decoding or encoding processes.
The ArrayManager
formats errors as an array of objects, where each object represents a distinct issue and includes properties such as _tag
, path
, and message
. This structured format can help developers quickly identify and address multiple issues in data processing.
Here's an example of how it works:
import { ArrayFormatter, Schema } from "@effect/schema"
import { Either } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const decode = Schema.decodeUnknownEither(Person)
const result = decode({})
if (Either.isLeft(result)) {
console.error("Decoding failed:")
console.error(ArrayFormatter.formatErrorSync(result.left))
}
Each error is formatted as an object in an array, making it clear what the error is (is missing
), where it occurred (name
), and its type (Missing
).
Handling Multiple Errors
By default, decoding functions like decodeUnknownEither
return only the first encountered error. If you require a comprehensive list of all errors, you can modify the behavior by passing the { errors: "all" }
option:
import { ArrayFormatter, Schema } from "@effect/schema"
import { Either } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const decode = Schema.decodeUnknownEither(Person, { errors: "all" })
const result = decode({})
if (Either.isLeft(result)) {
console.error("Decoding failed:")
console.error(ArrayFormatter.formatErrorSync(result.left))
}
React Hook Form
If you are working with React and need form validation, @hookform/resolvers
offers an adapter for @effect/schema
, which can be integrated with React Hook Form for enhanced form validation processes. This integration allows you to leverage the powerful features of @effect/schema
within your React applications.
For more detailed instructions and examples on how to integrate @effect/schema
with React Hook Form using @hookform/resolvers
, you can visit the official npm package page:
React Hook Form Resolvers
Type Guards
The Schema.is
function provided by the @effect/schema/Schema
module represents a way of verifying that a value conforms to a given Schema
. It functions 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. For instance, Schema<A, I, R>
where A
is the desired type.
-
Type Guard Creation: Convert the schema into a user-defined type guard (u: unknown) => u is A
. This allows you to assert at runtime whether a value meets the specified schema.
The type I
, typically used in schema transformations, does not influence the generation of the type guard. The primary focus is on ensuring that the input conforms to the desired type A
.
Example Usage:
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const isPerson = Schema.is(Person)
console.log(isPerson({ name: "Alice", age: 30 }))
console.log(isPerson(null))
console.log(isPerson({}))
Assertions
While type guards verify and inform about type conformity, the Schema.asserts
function takes it a step further by asserting that an input matches the schema A
type (from Schema<A, I, R>
). If the input does not match, it throws a detailed error.
Example Usage:
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const assertsPerson: Schema.Schema.ToAsserts<typeof Person> =
Schema.asserts(Person)
try {
assertsPerson({ name: "Alice", age: "30" })
} catch (e) {
console.error("The input does not match the schema:")
console.error(e)
}
assertsPerson({ name: "Alice", age: 30 })
Basic Usage
Cheatsheet
Typescript Type | Description / Notes | Schema / Combinator |
---|
null | | S.Null |
undefined | | S.Undefined |
string | | S.String |
number | | S.Number |
boolean | | S.Boolean |
symbol | | S.SymbolFromSelf / S.Symbol |
BigInt | | S.BigIntFromSelf / S.BigInt |
unknown | | S.Unknown |
any | | S.Any |
never | | S.Never |
object | | S.Object |
unique symbol | | S.UniqueSymbolFromSelf |
"a" , 1 , true | type literals | S.Literal("a") , S.Literal(1) , S.Literal(true) |
a${string} | template literals | S.TemplateLiteral("a", S.String) |
{ readonly a: string, readonly b: number } | structs | S.Struct({ a: S.String, b: S.Number }) |
{ readonly a?: string | undefined } | optional fields | S.Struct({ a: S.optional(S.String) }) |
{ readonly a?: string } | optional fields | S.Struct({ a: S.optionalWith(S.String, { exact: true }) }) |
Record<A, B> | records | S.Record({ key: A, value: B }) |
readonly [string, number] | tuples | S.Tuple(S.String, S.Number) |
ReadonlyArray<string> | arrays | S.Array(S.String) |
A | B | unions | S.Union(A, B) |
A & B | intersections of non-overlapping structs | S.extend(A, B) |
Record<A, B> & Record<C, D> | intersections of non-overlapping records | S.extend(S.Record({ key: A, value: B }), S.Record({ key: C, value: D })) |
type A = { readonly a: A | null } | recursive types | S.Struct({ a: S.Union(S.Null, S.suspend(() => self)) }) |
keyof A | | S.keyof(A) |
partial<A> | | S.partial(A) |
required<A> | | S.required(A) |
Primitives
Here are the primitive schemas provided by the @effect/schema/Schema
module:
import { Schema } from "@effect/schema"
Schema.String
Schema.Number
Schema.Boolean
Schema.BigIntFromSelf
Schema.SymbolFromSelf
Schema.Object
Schema.Undefined
Schema.Void
Schema.Any
Schema.Unknown
Schema.Never
These primitive schemas are building blocks for creating more complex schemas to describe your data structures.
Literals
Literals in schemas represent specific values that are directly specified. Here are some examples of literal schemas provided by the @effect/schema/Schema
module:
import { Schema } from "@effect/schema"
Schema.Null
Schema.Literal("a")
Schema.Literal("a", "b", "c")
Schema.Literal(1)
Schema.Literal(2n)
Schema.Literal(true)
We can also use pickLiteral
with a literal schema to narrow down the possible values:
import { Schema } from "@effect/schema"
Schema.Literal("a", "b", "c").pipe(Schema.pickLiteral("a", "b"))
Sometimes, we need to reuse a schema literal in other parts of our code. Let's see an example:
import { Schema } from "@effect/schema"
const FruitId = Schema.Number
const FruitCategory = Schema.Literal("sweet", "citrus", "tropical")
const Fruit = Schema.Struct({
id: FruitId,
category: FruitCategory
})
const SweetAndCitrusFruit = Schema.Struct({
fruitId: FruitId,
category: FruitCategory.pipe(Schema.pickLiteral("sweet", "citrus"))
})
In this example, FruitCategory
serves as the source of truth for the categories of fruits. We reuse it to create a subtype of Fruit
called SweetAndCitrusFruit
, ensuring that only the categories defined in FruitCategory
are allowed.
Exposed Values
You can access the literals of a literal schema:
import { Schema } from "@effect/schema"
const schema = Schema.Literal("a", "b")
const literals = schema.literals
Template literals
In TypeScript, template literals allow you to embed expressions within string literals. The @effect/schema
library provides a TemplateLiteral
constructor that you can use to create a schema for these template literal types.
Here's how you can use it:
import { Schema } from "@effect/schema"
Schema.TemplateLiteral("a", Schema.String)
Schema.TemplateLiteral(
"https://",
Schema.String,
".",
Schema.Literal("com", "net")
)
Let's look at a more complex example. Suppose you have two sets of locale IDs for emails and footers:
const EmailLocaleIDs = Schema.Literal("welcome_email", "email_heading")
const FooterLocaleIDs = Schema.Literal("footer_title", "footer_sendoff")
You can use the TemplateLiteral
constructor to create a schema that combines these IDs:
Schema.TemplateLiteral(Schema.Union(EmailLocaleIDs, FooterLocaleIDs), "_id")
The TemplateLiteral
constructor supports the following types of spans:
Schema.String
Schema.Number
- Literals:
string | number | boolean | null | bigint
. These can be either wrapped by Schema.Literal
or used directly - Unions of the above types
Unique Symbols
import { Schema } from "@effect/schema"
const mySymbol = Symbol.for("mysymbol")
const mySymbolSchema = Schema.UniqueSymbolFromSelf(mySymbol)
Filters
Using the Schema.filter
function, developers can define custom validation logic that goes beyond basic type checks, allowing for in-depth control over the data conformity process. This function applies a predicate to data, and if the data fails the predicate's condition, a custom error message can be returned.
Note. For effectful filters, see filterEffect
.
Simple Validation Example:
import { Schema } from "@effect/schema"
const LongString = Schema.String.pipe(
Schema.filter((s) =>
s.length >= 10 ? undefined : "a string at least 10 characters long"
)
)
console.log(Schema.decodeUnknownSync(LongString)("a"))
[!WARNING]
Please note that the use of filters do not alter the type of the Schema
. They only serve to add additional constraints to the parsing process. If you intend to modify the Type
, consider using Branded types.
Predicate Function Structure
The predicate for a filter is defined as follows:
type Predicate = (
a: A,
options: ParseOptions,
self: AST.Refinement
) => FilterReturnType
where
export interface FilterIssue {
readonly path: ReadonlyArray<PropertyKey>
readonly issue: string | ParseResult.ParseIssue
}
export type FilterOutput =
| undefined
| boolean
| string
| ParseResult.ParseIssue
| FilterIssue
type FilterReturnType = FilterOutput | ReadonlyArray<FilterOutput>
Filter predicates can return several types of values, each with specific implications:
true
: The data satisfies the filter's condition.false
or undefined
: The filter is not satisfied, and no specific error message is provided.string
: The filter fails, and the provided string is used as the default error message.ParseResult.ParseIssue
: The filter fails with a detailed error structure.FilterIssue
: Allows specifying detailed error paths and messages, enhancing error specificity.
An array can be returned if multiple issues need to be reported, allowing for complex validations that may have multiple points of failure.
Schema Metadata
It's beneficial to embed as much metadata as possible within the schema. This metadata can include identifiers, JSON schema specifications, and descriptive text to facilitate later analysis and understanding of the schema's purpose and constraints.
import { Schema } from "@effect/schema"
const LongString = Schema.String.pipe(
Schema.filter(
(s) =>
s.length >= 10 ? undefined : "a string at least 10 characters long",
{
identifier: "LongString",
jsonSchema: { minLength: 10 },
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua"
}
)
)
console.log(Schema.decodeUnknownSync(LongString)("a"))
Specifying Error Paths
It's possible to specify an error path along with the message, which enhances error specificity and is particularly beneficial for integration with tools like react-hook-form
.
import { ArrayFormatter, Schema } from "@effect/schema"
import { Either } from "effect"
const Password = Schema.Trim.pipe(Schema.minLength(1))
const MyForm = Schema.Struct({
password: Password,
confirm_password: Password
}).pipe(
Schema.filter((input) => {
if (input.password !== input.confirm_password) {
return {
path: ["confirm_password"],
message: "Passwords do not match"
}
}
})
)
console.log(
"%o",
Schema.decodeUnknownEither(MyForm)({
password: "abc",
confirm_password: "d"
}).pipe(Either.mapLeft((error) => ArrayFormatter.formatErrorSync(error)))
)
This allows the error to be directly associated with the confirm_password
field, improving clarity for the end-user.
Multiple Error Reporting
The filter
API also supports reporting multiple issues at once, which is useful in forms where several validation checks might fail simultaneously.
import { ArrayFormatter, Schema } from "@effect/schema"
import { Either } from "effect"
const Password = Schema.Trim.pipe(Schema.minLength(1))
const OptionalString = Schema.optional(Schema.String)
const MyForm = Schema.Struct({
password: Password,
confirm_password: Password,
name: OptionalString,
surname: OptionalString
}).pipe(
Schema.filter((input) => {
const issues: Array<Schema.FilterIssue> = []
if (input.password !== input.confirm_password) {
issues.push({
path: ["confirm_password"],
message: "Passwords do not match"
})
}
if (!input.name && !input.surname) {
issues.push({
path: ["surname"],
message: "Surname must be present if name is not present"
})
}
return issues
})
)
console.log(
"%o",
Schema.decodeUnknownEither(MyForm)({
password: "abc",
confirm_password: "d"
}).pipe(Either.mapLeft((error) => ArrayFormatter.formatErrorSync(error)))
)
Exposed Values
You can access the base schema for which the filter has been defined:
import { Schema } from "@effect/schema"
const LongString = Schema.String.pipe(Schema.filter((s) => s.length >= 10))
const From = LongString.from
In this example, you're able to access the original schema (Schema.String
) for which the filter (LongString
) has been defined. The from
property provides access to this base schema.
String Filters
import { Schema } from "@effect/schema"
Schema.String.pipe(Schema.maxLength(5))
Schema.String.pipe(Schema.minLength(5))
Schema.NonEmptyString
Schema.String.pipe(Schema.length(5))
Schema.String.pipe(Schema.length({ min: 2, max: 4 }))
Schema.String.pipe(Schema.pattern(regex))
Schema.String.pipe(Schema.startsWith(string))
Schema.String.pipe(Schema.endsWith(string))
Schema.String.pipe(Schema.includes(searchString))
Schema.String.pipe(Schema.trimmed())
Schema.String.pipe(Schema.lowercased())
[!NOTE]
The trimmed
combinator does not make any transformations, it only validates. If what you were looking for was a combinator to trim strings, then check out the trim
combinator ot the Trim
schema.
Number Filters
import { Schema } from "@effect/schema"
Schema.Number.pipe(Schema.greaterThan(5))
Schema.Number.pipe(Schema.greaterThanOrEqualTo(5))
Schema.Number.pipe(Schema.lessThan(5))
Schema.Number.pipe(Schema.lessThanOrEqualTo(5))
Schema.Number.pipe(Schema.between(-2, 2))
Schema.Number.pipe(Schema.int())
Schema.Number.pipe(Schema.nonNaN())
Schema.Number.pipe(Schema.finite())
Schema.Number.pipe(Schema.positive())
Schema.Number.pipe(Schema.nonNegative())
Schema.Number.pipe(Schema.negative())
Schema.Number.pipe(Schema.nonPositive())
Schema.Number.pipe(Schema.multipleOf(5))
BigInt Filters
import { Schema } from "@effect/schema"
Schema.BigInt.pipe(Schema.greaterThanBigInt(5n))
Schema.BigInt.pipe(Schema.greaterThanOrEqualToBigInt(5n))
Schema.BigInt.pipe(Schema.lessThanBigInt(5n))
Schema.BigInt.pipe(Schema.lessThanOrEqualToBigInt(5n))
Schema.BigInt.pipe(Schema.betweenBigInt(-2n, 2n))
Schema.BigInt.pipe(Schema.positiveBigInt())
Schema.BigInt.pipe(Schema.nonNegativeBigInt())
Schema.BigInt.pipe(Schema.negativeBigInt())
Schema.BigInt.pipe(Schema.nonPositiveBigInt())
BigDecimal Filters
import { Schema } from "@effect/schema"
import { BigDecimal } from "effect"
Schema.BigDecimal.pipe(Schema.greaterThanBigDecimal(BigDecimal.fromNumber(5)))
Schema.BigDecimal.pipe(
Schema.greaterThanOrEqualToBigDecimal(BigDecimal.fromNumber(5))
)
Schema.BigDecimal.pipe(Schema.lessThanBigDecimal(BigDecimal.fromNumber(5)))
Schema.BigDecimal.pipe(
Schema.lessThanOrEqualToBigDecimal(BigDecimal.fromNumber(5))
)
Schema.BigDecimal.pipe(
Schema.betweenBigDecimal(BigDecimal.fromNumber(-2), BigDecimal.fromNumber(2))
)
Schema.BigDecimal.pipe(Schema.positiveBigDecimal())
Schema.BigDecimal.pipe(Schema.nonNegativeBigDecimal())
Schema.BigDecimal.pipe(Schema.negativeBigDecimal())
Schema.BigDecimal.pipe(Schema.nonPositiveBigDecimal())
Duration Filters
import { Schema } from "@effect/schema"
Schema.Duration.pipe(Schema.greaterThanDuration("5 seconds"))
Schema.Duration.pipe(Schema.greaterThanOrEqualToDuration("5 seconds"))
Schema.Duration.pipe(Schema.lessThanDuration("5 seconds"))
Schema.Duration.pipe(Schema.lessThanOrEqualToDuration("5 seconds"))
Schema.Duration.pipe(Schema.betweenDuration("5 seconds", "10 seconds"))
Array Filters
import { Schema } from "@effect/schema"
Schema.Array(Schema.Number).pipe(Schema.maxItems(2))
Schema.Array(Schema.Number).pipe(Schema.minItems(2))
Schema.Array(Schema.Number).pipe(Schema.itemsCount(2))
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.
type UserId = string
type Username = string
const getUser = (id: UserId) => { ... }
const myUsername: Username = "gcanti"
getUser(myUsername)
In 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 avoid these kinds of issues, the @effect
ecosystem provides a way to create custom types with a unique identifier attached to them. These are known as "branded types".
import { Brand } from "effect"
type UserId = string & Brand.Brand<"UserId">
type Username = string
const getUser = (id: UserId) => { ... }
const myUsername: Username = "gcanti"
getUser(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/Brand
and want to reuse it to define a schema
Defining a schema from scratch
To define a schema for a branded type from scratch, you can use the brand
combinator exported by the @effect/schema/Schema
module. Here's an example:
import { Schema } from "@effect/schema"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
type UserId = Schema.Schema.Type<typeof UserId>
Note that you can use unique symbol
s as brands to ensure uniqueness across modules / packages:
import { Schema } from "@effect/schema"
const UserIdBrand = Symbol.for("UserId")
const UserId = Schema.String.pipe(Schema.brand(UserIdBrand))
type UserId = Schema.Schema.Type<typeof UserId>
Reusing an existing branded type
If you have already defined a branded type using the effect/Brand
module, you can reuse it to define a schema using the fromBrand
combinator exported by the @effect/schema/Schema
module. Here's an example:
import { Schema } from "@effect/schema"
import { Brand } from "effect"
type UserId = string & Brand.Brand<"UserId">
const UserId = Brand.nominal<UserId>()
const UserIdSchema = Schema.String.pipe(Schema.fromBrand(UserId))
Native enums
import { Schema } from "@effect/schema"
enum Fruits {
Apple,
Banana
}
const schema = Schema.Enums(Fruits)
Accessing Enum Members
Enums are exposed under an enums
property of the schema:
Schema.Enums(Fruits).enums
Schema.Enums(Fruits).enums.Apple
Schema.Enums(Fruits).enums.Banana
Nullables
import { Schema } from "@effect/schema"
Schema.NullOr(Schema.String)
Schema.NullishOr(Schema.String)
Schema.UndefinedOr(Schema.String)
Unions
@effect/schema/Schema
includes a built-in union
combinator for composing "OR" types.
import { Schema } from "@effect/schema"
Schema.Union(S.String, S.Number)
Union of Literals
While the following is perfectly acceptable:
import { Schema } from "@effect/schema"
const schema = Schema.Union(
Schema.Literal("a"),
Schema.Literal("b"),
Schema.Literal("c")
)
It is possible to use Literal
and pass multiple literals, which is less cumbersome:
import { Schema } from "@effect/schema"
const schema = Schema.Literal("a", "b", "c")
Under the hood, they are the same, as Literal(...literals)
will be converted into a union.
Discriminated unions
TypeScript reference: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions
Discriminated unions in TypeScript are a way of modeling complex data structures that may take on different forms based on a specific set of conditions or properties. They allow you to define a type that represents multiple related shapes, where each shape is uniquely identified by a shared discriminant property.
In a discriminated union, each variant of the union has a common property, called the discriminant. The discriminant is a literal type, which means it can only have a finite set of possible values. Based on the value of the discriminant property, TypeScript can infer which variant of the union is currently in use.
Here is an example of a discriminated union in TypeScript:
type Circle = {
readonly kind: "circle"
readonly radius: number
}
type Square = {
readonly kind: "square"
readonly sideLength: number
}
type Shape = Circle | Square
This code defines a discriminated union using the @effect/schema
library:
import { Schema } from "@effect/schema"
const Circle = Schema.Struct({
kind: Schema.Literal("circle"),
radius: Schema.Number
})
const Square = Schema.Struct({
kind: Schema.Literal("square"),
sideLength: Schema.Number
})
const Shape = Schema.Union(Circle, Square)
The Literal
combinator is used to define the discriminant property with a specific string literal value.
Two structs are defined for Circle
and Square
, each with their own properties. These structs represent the variants of the union.
Finally, the union
combinator is used to create a schema for the discriminated union Shape
, which is a union of Circle
and Square
.
How to transform a simple union into a discriminated union
If you're working on a TypeScript project and you've defined a simple union to represent a particular input, you may find yourself in a situation where you're not entirely happy with how it's set up. For example, let's say you've defined a Shape
union as a combination of Circle
and Square
without any special property:
import { Schema } from "@effect/schema"
const Circle = Schema.Struct({
radius: Schema.Number
})
const Square = Schema.Struct({
sideLength: Schema.Number
})
const Shape = Schema.Union(Circle, Square)
To make your code more manageable, you may want to transform the simple union into a discriminated union. This way, TypeScript will be able to automatically determine which member of the union you're working with based on the value of a specific property.
To achieve this, you can add a special property to each member of the union, which will allow TypeScript to know which type it's dealing with at runtime. Here's how you can transform the Shape
schema into another schema that represents a discriminated union:
import { Schema } from "@effect/schema"
import * as assert from "node:assert"
const Circle = Schema.Struct({
radius: Schema.Number
})
const Square = Schema.Struct({
sideLength: Schema.Number
})
const DiscriminatedShape = Schema.Union(
Circle.pipe(
Schema.transform(
Schema.Struct({ ...Circle.fields, kind: Schema.Literal("circle") }),
{
strict: true,
decode: (circle) => ({ ...circle, kind: "circle" as const }),
encode: ({ kind: _kind, ...rest }) => rest
}
)
),
Square.pipe(
Schema.transform(
Schema.Struct({ ...Square.fields, kind: Schema.Literal("square") }),
{
strict: true,
decode: (square) => ({ ...square, kind: "square" as const }),
encode: ({ kind: _kind, ...rest }) => rest
}
)
)
)
assert.deepStrictEqual(
Schema.decodeUnknownSync(DiscriminatedShape)({ radius: 10 }),
{
kind: "circle",
radius: 10
}
)
assert.deepStrictEqual(
Schema.decodeUnknownSync(DiscriminatedShape)({ sideLength: 10 }),
{
kind: "square",
sideLength: 10
}
)
The previous solution works perfectly and shows how we can add properties to our schema at will, making it easier to consume the result within our domain model. However, it requires a lot of boilerplate. Fortunately, there is an API called attachPropertySignature
designed specifically for this use case, which allows us to achieve the same result with much less effort:
import { Schema } from "@effect/schema"
import * as assert from "node:assert"
const Circle = Schema.Struct({ radius: Schema.Number })
const Square = Schema.Struct({ sideLength: Schema.Number })
const DiscriminatedShape = Schema.Union(
Circle.pipe(Schema.attachPropertySignature("kind", "circle")),
Square.pipe(Schema.attachPropertySignature("kind", "square"))
)
assert.deepStrictEqual(
Schema.decodeUnknownSync(DiscriminatedShape)({ radius: 10 }),
{
kind: "circle",
radius: 10
}
)
assert.deepStrictEqual(
Schema.encodeSync(DiscriminatedShape)({
kind: "circle",
radius: 10
}),
{ radius: 10 }
)
[!NOTE]
Please note that with attachPropertySignature
, you can only add a property, it cannot override an existing one.
Exposed Values
You can access the members of a union schema:
import { Schema } from "@effect/schema"
const schema = Schema.Union(Schema.String, Schema.Number)
const members = schema.members
Tuples
Required Elements
To define a tuple with required elements, you simply specify the list of elements:
import { Schema } from "@effect/schema"
const opaque = Schema.Tuple(Schema.String, Schema.Number)
const nonOpaque = Schema.asSchema(opaque)
Append a Required Element
import { Schema } from "@effect/schema"
const tuple1 = Schema.Tuple(Schema.String, Schema.Number)
const tuple2 = Schema.Tuple(...tuple1.elements, Schema.Boolean)
Optional Elements
To define an optional element, wrap the schema of the element with the OptionalElement
modifier:
import { Schema } from "@effect/schema"
const opaque = Schema.Tuple(
Schema.String,
Schema.optionalElement(Schema.Number)
)
const nonOpaque = Schema.asSchema(opaque)
Rest Element
To define rest elements, follow the list of elements (required or optional) with an element for the rest:
import { Schema } from "@effect/schema"
const opaque = Schema.Tuple(
[Schema.String, Schema.optionalElement(Schema.Number)],
Schema.Boolean
)
const nonOpaque = Schema.asSchema(opaque)
Optionally, you can include other elements after the rest:
import { Schema } from "@effect/schema"
const opaque = Schema.Tuple(
[Schema.String, Schema.optionalElement(Schema.Number)],
Schema.Boolean,
Schema.String
)
const nonOpaque = Schema.asSchema(opaque)
Exposed Values
You can access the elements and rest elements of a tuple schema:
import { Schema } from "@effect/schema"
const schema = Schema.Tuple(
[Schema.String, Schema.optionalElement(Schema.Number)],
Schema.Boolean,
Schema.Number
)
const tupleElements = schema.elements
const restElements = schema.rest
Annotations
Annotations are used to add metadata to tuple elements, which can describe the purpose or requirements of each element more clearly. This can be particularly useful when generating documentation or JSON schemas from your schemas.
import { JSONSchema, Schema } from "@effect/schema"
const Point = Schema.Tuple(
Schema.element(Schema.Number).annotations({
title: "X",
description: "X coordinate"
}),
Schema.optionalElement(Schema.Number).annotations({
title: "Y",
description: "optional Y coordinate"
})
)
console.log(JSONSchema.make(Point))
Arrays
import { Schema } from "@effect/schema"
const opaque = Schema.Array(Schema.Number)
const schema = Schema.asSchema(opaque)
Exposed Values
You can access the value of an array schema:
import { Schema } from "@effect/schema"
const schema = Schema.Array(Schema.String)
const value = schema.value
Mutable Arrays
By default, when you use S.Array
, it generates a type marked as readonly. The mutable
combinator is a useful function for creating a new schema with a mutable type in a shallow manner:
import { Schema } from "@effect/schema"
const opaque = Schema.mutable(Schema.Array(Schema.Number))
const schema = Schema.asSchema(opaque)
Non empty arrays
import { Schema } from "@effect/schema"
const opaque = Schema.NonEmptyArray(Schema.Number)
const schema = Schema.asSchema(opaque)
Exposed Values
You can access the value of a non-empty array schema:
import { Schema } from "@effect/schema"
const schema = Schema.NonEmptyArray(Schema.String)
const value = schema.value
Records
String keys
import { Schema } from "@effect/schema"
const opaque1 = Schema.Record({ key: Schema.String, value: Schema.Number })
const schema1 = Schema.asSchema(opaque1)
const opaque2 = Schema.Record({
key: Schema.Union(Schema.Literal("a"), Schema.Literal("b")),
value: Schema.Number
})
const schema2 = Schema.asSchema(opaque2)
Keys refinements
import { Schema } from "@effect/schema"
const opaque = Schema.Record({
key: Schema.String.pipe(Schema.minLength(2)),
value: Schema.Number
})
const schema = Schema.asSchema(opaque)
Symbol keys
import { Schema } from "@effect/schema"
const opaque = Schema.Record({
key: Schema.SymbolFromSelf,
value: Schema.Number
})
const schema = Schema.asSchema(opaque)
Template literal keys
import { Schema } from "@effect/schema"
const opaque = Schema.Record({
key: Schema.TemplateLiteral(Schema.Literal("a"), Schema.String),
value: Schema.Number
})
const schema = Schema.asSchema(opaque)
Mutable Records
By default, when you use S.Record
, it generates a type marked as readonly. The mutable
combinator is a useful function for creating a new schema with a mutable type in a shallow manner:
import { Schema } from "@effect/schema"
const opaque = Schema.mutable(
Schema.Record({ key: Schema.String, value: Schema.Number })
)
const schema = Schema.asSchema(opaque)
Exposed Values
You can access the key and the value of a record schema:
import { Schema } from "@effect/schema"
const schema = Schema.Record({ key: Schema.String, value: Schema.Number })
const key = schema.key
const value = schema.value
Structs
In @effect/schema
, structs are used to define schemas for objects with specific properties. Here's how you can create and use a struct schema:
import { Schema } from "@effect/schema"
const MyStruct = Schema.Struct({ a: Schema.String, b: Schema.Number })
The MyStruct
constant will have the type Schema.Struct<{ a: typeof Schema.String; b: typeof Schema.Number; }>
, representing the structure of the object.
To view the detailed type of MyStruct
, you can use the Schema.asSchema
function:
const schema = Schema.asSchema(MyStruct)
[!NOTE]
Note that Schema.Struct({})
models the TypeScript type {}
, which is similar to unknown
. This means that the schema will allow any type of data to pass through without validation.
Index Signatures
The Struct
constructor optionally accepts a list of key/value pairs representing index signatures:
(props, ...indexSignatures) => Struct<...>
Example
import { Schema } from "@effect/schema"
const opaque = Schema.Struct(
{
a: Schema.Number
},
{ key: Schema.String, value: Schema.Number }
)
const nonOpaque = Schema.asSchema(opaque)
Since the Record
constructor returns a schema that exposes both the key
and the value
, instead of passing a bare object { key, value }
, you can use the Record
constructor:
import { Schema } from "@effect/schema"
const opaque = Schema.Struct(
{ a: Schema.Number },
Schema.Record({ key: Schema.String, value: Schema.Number })
)
const nonOpaque = Schema.asSchema(opaque)
Exposed Values
You can access the fields and the records of a struct schema:
import { Schema } from "@effect/schema"
const schema = Schema.Struct(
{ a: Schema.Number },
Schema.Record({ key: Schema.String, value: Schema.Number })
)
const fields = schema.fields
const records = schema.records
Mutable Properties
By default, when you use S.struct
, it generates a type with properties that are marked as readonly. The mutable
combinator is a useful function for creating a new schema with properties made mutable in a shallow manner:
import { Schema } from "@effect/schema"
const opaque = Schema.mutable(
Schema.Struct({ a: Schema.String, b: Schema.Number })
)
const schema = Schema.asSchema(opaque)
Property Signatures
A PropertySignature
generally represents a transformation from a "From" field:
{
fromKey: fromType
}
to a "To" field:
{
toKey: toType
}
Let's start with the simple definition of a property signature that can be used to add annotations:
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.propertySignature(Schema.NumberFromString).annotations({
title: "Age"
})
})
Let's delve into the details of all the information contained in the type of a PropertySignature
:
age: PropertySignature<
ToToken,
ToType,
FromKey,
FromToken,
FromType,
HasDefault,
Context
>
age
: is the key of the "To" fieldToToken
: either "?:"
or ":"
, "?:"
indicates that the "To" field is optional, ":"
indicates that the "To" field is requiredToType
: the type of the "To" fieldFromKey
(optional, default = never
): indicates the key from the field from which the transformation starts, by default it is equal to the key of the "To" field (i.e., "age"
in this case)FormToken
: either "?:"
or ":"
, "?:"
indicates that the "From" field is optional, ":"
indicates that the "From" field is requiredFromType
: the type of the "From" fieldHasDefault
: indicates whether it has a constructor default value.
In our case, the type
PropertySignature<":", number, never, ":", string, false, never>
indicates that there is the following transformation:
age
is the key of the "To" fieldToToken = ":"
indicates that the age
field is requiredToType = number
indicates that the type of the age
field is number
FromKey = never
indicates that the decoding occurs from the same field named age
FormToken = "."
indicates that the decoding occurs from a required age
fieldFromType = string
indicates that the decoding occurs from a string
type age
fieldHasDefault = false
: no default.
Let's see an example of decoding:
console.log(Schema.decodeUnknownSync(Person)({ name: "name", age: "18" }))
Now, suppose the field from which decoding occurs is named "AGE"
, but for our model, we want to keep the name in lowercase "age"
. To achieve this result, we need to map the field key from "AGE"
to "age"
, and to do that, we can use the fromKey
combinator:
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.propertySignature(Schema.NumberFromString).pipe(
Schema.fromKey("AGE")
)
})
This modification is represented in the type of the created PropertySignature
:
PropertySignature<":", number, "AGE", ":", string, false, never>
Now, let's see an example of decoding:
console.log(Schema.decodeUnknownSync(Person)({ name: "name", AGE: "18" }))
Optional Fields
Cheatsheet
Combinator | From | To |
---|
optional | Schema<A, I, R> | PropertySignature<"?:", string | undefined, never, "?:", string | undefined, never> |
optional | Schema<A, I, R> , { nullable: true } | PropertySignature<"?:", string | null | undefined, never, "?:", string | null | undefined, never> |
optional | Schema<A, I, R> , { exact: true } | PropertySignature<"?:", string, never, "?:", string, never> |
optional | Schema<A, I, R> , { exact: true, nullable: true } | PropertySignature<"?:", string | null, never, "?:", string | null, never> |
optional(schema)
- decoding
<missing value>
-> <missing value>
undefined
-> undefined
i
-> a
- encoding
<missing value>
-> <missing value>
undefined
-> undefined
a
-> i
optionalWith(schema, { nullable: true })
- decoding
<missing value>
-> <missing value>
undefined
-> undefined
null
-> <missing value>
i
-> a
- encoding
<missing value>
-> <missing value>
undefined
-> undefined
a
-> i
optionalWith(schema, { exact: true })
- decoding
<missing value>
-> <missing value>
i
-> a
- encoding
<missing value>
-> <missing value>
a
-> i
optionalWith(schema, { exact: true, nullable: true })
- decoding
<missing value>
-> <missing value>
null
-> <missing value>
i
-> a
- encoding
<missing value>
-> <missing value>
a
-> i
Default Values
The default
option allows you to set a default value for both the decoding phase and the default constructor.
Example
Let's see how default values work in both the decoding and constructing phases, illustrating how the default value is applied when certain properties are not provided.
import { Schema } from "@effect/schema"
const Product = Schema.Struct({
name: Schema.String,
price: Schema.NumberFromString,
quantity: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 })
})
console.log(Schema.decodeUnknownSync(Product)({ name: "Laptop", price: "999" }))
console.log(
Schema.decodeUnknownSync(Product)({
name: "Laptop",
price: "999",
quantity: "2"
})
)
console.log(Product.make({ name: "Laptop", price: 999 }))
console.log(Product.make({ name: "Laptop", price: 999, quantity: 2 }))
Combinator | From | To |
---|
optional | Schema<A, I, R> , { default: () => A } | PropertySignature<":", string, never, "?:", string | undefined, never> |
optional | Schema<A, I, R> , { exact: true, default: () => A } | PropertySignature<":", string, never, "?:", string, never> |
optional | Schema<A, I, R> , { nullable: true, default: () => A } | PropertySignature<":", string, never, "?:", string | null | undefined, never> |
optional | Schema<A, I, R> , { exact: true, nullable: true, default: () => A } | PropertySignature<":", string, never, "?:", string | null, never> |
optionalWith(schema, { default: () => A })
- decoding
<missing value>
-> <default value>
undefined
-> <default value>
i
-> a
- encoding
optionalWith(schema, { exact: true, default: () => A })
- decoding
<missing value>
-> <default value>
i
-> a
- encoding
optionalWith(schema, { nullable: true, default: () => A })
- decoding
<missing value>
-> <default value>
undefined
-> <default value>
null
-> <default value>
i
-> a
- encoding
optionalWith(schema, { exact: true, nullable: true, default: () => A })
- decoding
<missing value>
-> <default value>
null
-> <default value>
i
-> a
- encoding
Optional Fields as Option
s
Combinator | From | To |
---|
optional | Schema<A, I, R> , { as: "Option" } | PropertySignature<":", Option<string>, never, "?:", string | undefined, never> |
optional | Schema<A, I, R> , { exact: true, as: "Option" } | PropertySignature<":", Option<string>, never, "?:", string, never> |
optional | Schema<A, I, R> , { nullable: true, as: "Option" } | PropertySignature<":", Option<string>, never, "?:", string | null | undefined, never> |
optional | Schema<A, I, R> , { exact: true, nullable: true, as: "Option" } | PropertySignature<":", Option<string>, never, "?:", string | null, never> |
optionalWith(schema, { as: "Option" })
- decoding
<missing value>
-> Option.none()
undefined
-> Option.none()
i
-> Option.some(a)
- encoding
Option.none()
-> <missing value>
Option.some(a)
-> i
optionalWith(schema, { exact: true, as: "Option" })
- decoding
<missing value>
-> Option.none()
i
-> Option.some(a)
- encoding
Option.none()
-> <missing value>
Option.some(a)
-> i
optionalWith(schema, { nullable: true, as: "Option" })
- decoding
<missing value>
-> Option.none()
undefined
-> Option.none()
null
-> Option.none()
i
-> Option.some(a)
- encoding
Option.none()
-> <missing value>
Option.some(a)
-> i
optionalWith(schema, { exact: true, nullable: true, as: "Option" })
- decoding
<missing value>
-> Option.none()
null
-> Option.none()
i
-> Option.some(a)
- encoding
Option.none()
-> <missing value>
Option.some(a)
-> i
Optional Fields Primitives
The optional
API is based on two primitives: optionalToOptional
and optionalToRequired
. These primitives are incredibly useful for defining property signatures with more precision.
optionalToOptional
The optionalToOptional
API is used to manage the transformation from an optional field to another optional field. With this, we can control both the output type and the presence or absence of the field.
For example a common use case is to equate a specific value in the source field with the absence of value in the destination field.
Here's the signature of the optionalToOptional
API:
export 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>
As you can see, we can transform the type by specifying a schema for to
, which can be different from the schema of from
. Additionally, we can control the presence or absence of the field using decode
and encode
, with the following meanings:
decode
:
none
as an argument means the value is missing in the inputnone
as a return value means the value will be missing in the output
encode
:
none
as an argument means the value is missing in the inputnone
as a return value means the value will be missing in the output
Example
Suppose we have an optional field of type string
, and we want to exclude empty strings from the output. In other words, if the input contains an empty string, we want the field to be absent in the output.
import { Schema } from "@effect/schema"
import { identity, Option } from "effect"
const schema = Schema.Struct({
a: Schema.optionalToOptional(Schema.String, Schema.String, {
decode: (input) => {
if (Option.isNone(input)) {
return Option.none()
}
const value = input.value
if (value === "") {
return Option.none()
}
return Option.some(value)
},
encode: identity
})
})
const decode = Schema.decodeUnknownSync(schema)
console.log(decode({}))
console.log(decode({ a: "" }))
console.log(decode({ a: "a non-empty string" }))
const encode = Schema.encodeSync(schema)
console.log(encode({}))
console.log(encode({ a: "" }))
console.log(encode({ a: "foo" }))
optionalToRequired
The optionalToRequired
API allows us to transform an optional field into a required one, applying custom logic if the field is absent in the input.
export 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>
For instance, a common use case is to assign a default value to the field in the output if it's missing in the input. Let's see an example:
import { Schema } from "@effect/schema"
import { Option } from "effect"
const schema = Schema.Struct({
a: Schema.optionalToRequired(Schema.String, Schema.String, {
decode: (input) => {
if (Option.isNone(input)) {
return "default value"
}
return input.value
},
encode: (a) => Option.some(a)
})
})
const decode = Schema.decodeUnknownSync(schema)
console.log(decode({}))
console.log(decode({ a: "foo" }))
const encode = Schema.encodeSync(schema)
console.log(encode({ a: "foo" }))
requiredToOptional
This API allows developers to specify how a field that is normally required can be treated as optional based on custom logic.
export 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>
from
and to
Schemas: Define the starting and ending schemas for the transformation.decode
: Custom logic for transforming the required input into an optional output.encode
: Defines how to handle the potentially optional input when encoding it back to a required output.
Example
Let's look at a practical example where a field name
that is typically required can be considered optional if it's an empty string during decoding, and ensure there is always a value during encoding by providing a default.
import { Schema } from "@effect/schema"
import { Option } from "effect"
const schema = Schema.Struct({
name: Schema.requiredToOptional(Schema.String, Schema.String, {
decode: Option.liftPredicate((s) => s !== ""),
encode: Option.getOrElse(() => "")
})
})
const decode = Schema.decodeUnknownSync(schema)
console.log(decode({ name: "John" }))
console.log(decode({ name: "" }))
const encode = Schema.encodeSync(schema)
console.log(encode({ name: "John" }))
console.log(encode({}))
Renaming a Property During Definition
To rename a property directly during schema creation, you can utilize the Schema.fromKey
function. This function is particularly useful when you want to map properties from the input object to different names in the resulting schema object.
Example: Renaming a Required Property
import { Schema } from "@effect/schema"
const schema = Schema.Struct({
a: Schema.propertySignature(Schema.String).pipe(Schema.fromKey("c")),
b: Schema.Number
})
console.log(Schema.decodeUnknownSync(schema)({ c: "c", b: 1 }))
Example: Renaming an Optional Property
import { Schema } from "@effect/schema"
const schema = Schema.Struct({
a: Schema.optional(Schema.String).pipe(Schema.fromKey("c")),
b: Schema.Number
})
console.log(Schema.decodeUnknownSync(schema)({ c: "c", b: 1 }))
console.log(Schema.decodeUnknownSync(schema)({ b: 1 }))
Note that Schema.optional
returns a PropertySignature
, which simplifies the process by eliminating the need for explicit Schema.propertySignature
usage as required in previous versions.
Renaming Properties of an Existing Schema
For existing schemas, the rename
API offers a way to systematically change property names across a schema, even within complex structures like unions.
Example: Renaming Properties in a Struct Schema
import { Schema } from "@effect/schema"
const originalSchema = Schema.Struct({
c: Schema.String,
b: Schema.Number
})
const renamedSchema = Schema.rename(originalSchema, { c: "a" })
console.log(Schema.decodeUnknownSync(renamedSchema)({ c: "c", b: 1 }))
Example: Renaming Properties in Union Schemas
import { Schema } from "@effect/schema"
const originalSchema = Schema.Union(
Schema.Struct({
c: Schema.String,
b: Schema.Number
}),
Schema.Struct({
c: Schema.String,
d: Schema.Boolean
})
)
const renamedSchema = Schema.rename(originalSchema, { c: "a" })
console.log(Schema.decodeUnknownSync(renamedSchema)({ c: "c", b: 1 }))
console.log(Schema.decodeUnknownSync(renamedSchema)({ c: "c", d: false }))
Tagged Structs
In TypeScript tags help to enhance type discrimination and pattern matching by providing a simple yet powerful way to define and recognize different data types.
What is a Tag?
A tag is a literal value added to data structures, commonly used in structs, to distinguish between various object types or variants within tagged unions. This literal acts as a discriminator, making it easier to handle and process different types of data correctly and efficiently.
Using the tag
Constructor
The tag
constructor is specifically designed to create a property signature that holds a specific literal value, serving as the discriminator for object types. Here's how you can define a schema with a tag:
import { Schema } from "@effect/schema"
const User = Schema.Struct({
_tag: Schema.tag("User"),
name: Schema.String,
age: Schema.Number
})
assert.deepStrictEqual(User.make({ name: "John", age: 44 }), {
_tag: "User",
name: "John",
age: 44
})
In the example above, Schema.tag("User")
attaches a _tag
property to the User
struct schema, effectively labeling objects of this struct type as "User". This label is automatically applied when using the make
method to create new instances, simplifying object creation and ensuring consistent tagging.
Simplifying Tagged Structs with TaggedStruct
The TaggedStruct
constructor streamlines the process of creating tagged structs by directly integrating the tag into the struct definition. This method provides a clearer and more declarative approach to building data structures with embedded discriminators.
import { Schema } from "@effect/schema"
const User = Schema.TaggedStruct("User", {
name: Schema.String,
age: Schema.Number
})
const userInstance = User.make({ name: "John", age: 44 })
assert.deepStrictEqual(userInstance, {
_tag: "User",
name: "John",
age: 44
})
Multiple Tags
While a primary tag is often sufficient, TypeScript allows you to define multiple tags for more complex data structuring needs. Here's an example demonstrating the use of multiple tags within a single struct:
import { Schema } from "@effect/schema"
const Product = Schema.TaggedStruct("Product", {
category: Schema.tag("Electronics"),
name: Schema.String,
price: Schema.Number
})
const productInstance = Product.make({ name: "Smartphone", price: 999 })
assert.deepStrictEqual(productInstance, {
_tag: "Product",
category: "Electronics",
name: "Smartphone",
price: 999
})
This example showcases a product schema that not only categorizes each product under a general tag ("Product"
) but also specifies a category tag ("Electronics"
), enhancing the clarity and specificity of the data model.
instanceOf
When you need to define a schema for your custom data type defined through a class
, the most convenient and fast way is to use the Schema.instanceOf
constructor. Let's see an example:
import { Schema } from "@effect/schema"
class MyData {
constructor(readonly name: string) {}
}
const MyDataSchema = Schema.instanceOf(MyData)
console.log(Schema.decodeUnknownSync(MyDataSchema)(new MyData("name")))
console.log(Schema.decodeUnknownSync(MyDataSchema)({ name: "name" }))
The Schema.instanceOf
constructor is just a lightweight wrapper of the Schema.declare
API, which is the primitive in @effect/schema
for declaring new custom data types.
However, note that instanceOf
can only be used for classes that expose a public constructor. If you try to use it with classes that, for some reason, have marked the constructor as private
, you'll receive a TypeScript error:
import { Schema } from "@effect/schema"
class MyData {
static make = (name: string) => new MyData(name)
private constructor(readonly name: string) {}
}
const MyDataSchema = Schema.instanceOf(MyData)
In such cases, you cannot use Schema.instanceOf
, and you must rely on Schema.declare
like this:
import { Schema } from "@effect/schema"
class MyData {
static make = (name: string) => new MyData(name)
private constructor(readonly name: string) {}
}
const MyDataSchema = Schema.declare(
(input: unknown): input is MyData => input instanceof MyData
)
console.log(Schema.decodeUnknownSync(MyDataSchema)(MyData.make("name")))
console.log(Schema.decodeUnknownSync(MyDataSchema)({ name: "name" }))
To improve the error message in case of failed decoding, remember to add annotations:
const MyDataSchema = Schema.declare(
(input: unknown): input is MyData => input instanceof MyData,
{
identifier: "MyData",
description: "an instance of MyData"
}
)
console.log(Schema.decodeUnknownSync(MyDataSchema)({ name: "name" }))
pick
The pick
static function available in each struct schema can be used to create a new Struct
by selecting particular properties from an existing Struct
.
import { Schema } from "@effect/schema"
const MyStruct = Schema.Struct({
a: Schema.String,
b: Schema.Number,
c: Schema.Boolean
})
const PickedSchema = MyStruct.pick("a", "c")
The Schema.pick
function can be applied more broadly beyond just Struct
types, such as with unions of schemas. However it returns a generic SchemaClass
.
Example: Picking from a Union
import { Schema } from "@effect/schema"
const MyUnion = Schema.Union(
Schema.Struct({ a: Schema.String, b: Schema.String, c: Schema.String }),
Schema.Struct({ a: Schema.Number, b: Schema.Number, d: Schema.Number })
)
const PickedSchema = MyUnion.pipe(Schema.pick("a", "b"))
omit
The omit
static function available in each struct schema can be used to create a new Struct
by excluding particular properties from an existing Struct
.
import { Schema } from "@effect/schema"
const MyStruct = Schema.Struct({
a: Schema.String,
b: Schema.Number,
c: Schema.Boolean
})
const PickedSchema = MyStruct.omit("b")
The Schema.omit
function can be applied more broadly beyond just Struct
types, such as with unions of schemas. However it returns a generic SchemaClass
.
import { Schema } from "@effect/schema"
const MyUnion = Schema.Union(
Schema.Struct({ a: Schema.String, b: Schema.String, c: Schema.String }),
Schema.Struct({ a: Schema.Number, b: Schema.Number, d: Schema.Number })
)
const PickedSchema = MyUnion.pipe(Schema.omit("b"))
partial
The partial
operation makes all properties within a schema optional.
By default, the partial
operation adds a union with undefined
to the types. If you wish to avoid this, you can opt-out by passing a { exact: true }
argument to the partial
operation.
Example
import { Schema } from "@effect/schema"
const schema = Schema.partial(Schema.Struct({ a: Schema.String }))
Schema.decodeUnknownSync(schema)({ a: "a" })
Schema.decodeUnknownSync(schema)({ a: undefined })
const exactSchema = Schema.partialWith(Schema.Struct({ a: Schema.String }), {
exact: true
})
Schema.decodeUnknownSync(exactSchema)({ a: "a" })
Schema.decodeUnknownSync(exactSchema)({ a: undefined })
required
The required
operation ensures that all properties in a schema are mandatory.
import { Schema } from "@effect/schema"
Schema.required(
Schema.Struct({
a: Schema.optionalWith(Schema.String, { exact: true }),
b: Schema.optionalWith(Schema.Number, { exact: true })
})
)
Extending Schemas
Spreading Struct fields
Structs expose their fields through a fields
property. This feature can be utilized to extend an existing struct with additional fields or to merge fields from another struct. Here's how you can enhance the functionality of your schemas by spreading fields:
Example: Adding Fields
import { Schema } from "@effect/schema"
const Struct1 = Schema.Struct({
a: Schema.String,
b: Schema.String
})
const Extended = Schema.Struct({
...Struct1.fields,
c: Schema.String,
d: Schema.String
})
Example: Integrating Additional Property Signatures
import { Schema } from "@effect/schema"
const Struct = Schema.Struct({
a: Schema.String,
b: Schema.String
})
const Extended = Schema.Struct(
{
...Struct.fields
},
Schema.Record({ key: Schema.String, value: Schema.String })
)
Example: Merging Fields from Two Structs
import { Schema } from "@effect/schema"
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
})
The extend combinator
The extend
combinator offers a structured way to extend schemas, particularly useful when direct field spreading is insufficient—for instance, when you need to extend a struct with a union of structs.
[!NOTE]
Note that not all extensions are supported, and their support depends on the nature of the involved schemas:
Possible extensions include:
Schema.String
with another Schema.String
refinement or a string literalSchema.Number
with another Schema.Number
refinement or a number literalSchema.Boolean
with another Schema.Boolean
refinement 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 suspend of a struct with a supported schema
Example: Extending a Struct with a Union of Structs
import { Schema } from "@effect/schema"
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)
This example shows an attempt to extend a struct with another struct where field names overlap, leading to an error:
import { Schema } from "@effect/schema"
const Struct = Schema.Struct({
a: Schema.String
})
const OverlappingUnion = Schema.Union(
Schema.Struct({ a: Schema.Number }),
Schema.Struct({ d: Schema.String })
)
const Extended = Schema.extend(Struct, OverlappingUnion)
Example: Extending a refinement of Schema.String with another refinement
import { Schema } from "@effect/schema"
const Integer = Schema.Int.pipe(Schema.brand("Int"))
const Positive = Schema.Positive.pipe(Schema.brand("Positive"))
const PositiveInteger = Schema.asSchema(Schema.extend(Positive, Integer))
Schema.decodeUnknownSync(PositiveInteger)(-1)
Schema.decodeUnknownSync(PositiveInteger)(1.1)
Composition
Combining and reusing schemas is a common requirement, the compose
combinator allows you to do just that. It enables you to combine two schemas, Schema<B, A, R1>
and Schema<C, B, R2>
, into a single schema Schema<C, A, R1 | R2>
:
import { Schema } from "@effect/schema"
const schema1 = Schema.split(",")
const schema2 = Schema.Array(Schema.NumberFromString)
const ComposedSchema = Schema.compose(schema1, schema2)
In this example, we have two schemas, schema1
and schema2
. The first schema, schema1
, takes a string and splits it into an array using a comma as the delimiter. The second schema, schema2
, transforms an array of strings into an array of numbers.
Now, by using the compose
combinator, we can create a new schema, ComposedSchema
, that combines the functionality of both schema1
and schema2
. This allows us to parse a string and directly obtain an array of numbers as a result.
Non-strict Option
If you need to be less restrictive when composing your schemas, i.e., when you have something like Schema<R1, A, B>
and Schema<R2, C, D>
where C
is different from B
, you can make use of the { strict: false }
option:
declare const compose: <A, B, R1, D, C, R2>(
from: Schema<B, A, R1>,
to: Schema<D, C, R2>,
options: { readonly strict: false }
) => Schema<D, A, R1 | R2>
This is useful when you want to relax the type constraints imposed by the decode
and encode
functions, making them more permissive:
import { Schema } from "@effect/schema"
Schema.compose(
Schema.Union(Schema.Null, Schema.String),
Schema.NumberFromString
)
Schema.compose(
Schema.Union(Schema.Null, Schema.String),
Schema.NumberFromString,
{ strict: false }
)
Projections
typeSchema
The typeSchema
function allows you to extract the Type
portion of a schema, creating a new schema that conforms to the properties defined in the original schema without considering the initial encoding or transformation processes.
Function Signature:
export const typeSchema = <A, I, R>(schema: Schema<A, I, R>): Schema<A>
Example Usage:
import { Schema } from "@effect/schema"
const schema = Schema.Struct({
foo: Schema.NumberFromString.pipe(Schema.greaterThanOrEqualTo(2))
})
const resultingTypeSchema = Schema.typeSchema(schema)
Schema.Struct({
foo: Schema.Number.pipe(Schema.greaterThanOrEqualTo(2))
})
In this example:
- Original Schema: The schema for
foo
is initially defined to accept a number from a string and enforce that it is greater than or equal to 2. - Resulting Type Schema: The
typeSchema
extracts only the type-related information from foo
, simplifying it to just a number while still maintaining the constraint that it must be greater than or equal to 2.
encodedSchema
The encodedSchema
function allows you to extract the Encoded
portion of a schema, creating a new schema that conforms to the properties defined in the original schema without retaining any refinements or transformations that were applied previously.
Function Signature:
export const encodedSchema = <A, I, R>(schema: Schema<A, I, R>): Schema<I>
Example Usage:
Attenzione che encodedSchema
non preserva i refinements:
import { Schema } from "@effect/schema"
const schema = Schema.Struct({
foo: Schema.String.pipe(Schema.minLength(3))
})
const resultingEncodedSchema = Schema.encodedSchema(schema)
Schema.Struct({
foo: Schema.String
})
In this example:
- Original Schema Definition: The
foo
field in the schema is defined as a string with a minimum length of three characters. - Resulting Encoded Schema: The
encodedSchema
function simplifies the foo
field to just a string type, effectively stripping away the minLength
refinement.
encodedBoundSchema
The encodedBoundSchema
function is similar to encodedSchema
but preserves the refinements up to the first transformation point in the
original schema.
Function Signature:
export const encodedBoundSchema = <A, I, R>(schema: Schema<A, I, R>): Schema<I>
The term "bound" in this context refers to the boundary up to which refinements are preserved when extracting the encoded form of a schema. It essentially marks the limit to which initial validations and structure are maintained before any transformations are applied.
Example Usage:
import { Schema } from "@effect/schema"
const schema = Schema.Struct({
foo: Schema.String.pipe(Schema.minLength(3), Schema.compose(Schema.Trim))
})
const resultingEncodedBoundSchema = Schema.encodedBoundSchema(schema)
Schema.Struct({
foo: Schema.String.pipe(Schema.minLength(3))
})
In the provided example:
- Initial Schema: The schema for
foo
includes a refinement to ensure strings have a minimum length of three characters and a transformation to trim the string. - Resulting Schema:
resultingEncodedBoundSchema
maintains the minLength(3)
condition, ensuring that this validation persists. However, it excludes the trimming transformation, focusing solely on the length requirement without altering the string's formatting.
Useful Examples
Email
Since there are various different definitions of what constitutes a valid email address depending on the environment and use case, @effect/schema
does not provide a built-in combinator for parsing email addresses. However, it is easy to define a custom combinator that can be used to parse email addresses.
import { Schema } from "@effect/schema"
const Email = Schema.pattern(
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i
)
Url
Multiple environments like the Browser or Node provide a built-in URL
class that can be used to validate URLs. Here we demonstrate how to leverage it to validate if a string is a valid URL.
import { Schema } from "@effect/schema"
const UrlString = Schema.String.pipe(
Schema.filter((value) => {
try {
new URL(value)
return true
} catch (_) {
return false
}
})
)
const decode = Schema.decodeUnknownSync(UrlString)
console.log(decode("https://www.effect.website"))
In case you prefer to normalize URLs you can combine transformOrFail
with URL
:
import { ParseResult, Schema } from "@effect/schema"
const NormalizedUrlString = Schema.String.pipe(
Schema.filter((value) => {
try {
return new URL(value).toString() === value
} catch (_) {
return false
}
})
)
const NormalizeUrlString = Schema.transformOrFail(
Schema.String,
NormalizedUrlString,
{
strict: true,
decode: (value, _, ast) =>
ParseResult.try({
try: () => new URL(value).toString(),
catch: (err) =>
new ParseResult.Type(
ast,
value,
err instanceof Error ? err.message : undefined
)
}),
encode: ParseResult.succeed
}
)
const decode = Schema.decodeUnknownSync(NormalizeUrlString)
console.log(decode("https://www.effect.website"))
Declaring New Data Types
Creating schemas for new data types is crucial to defining the expected structure of information in your application. This guide explores how to declare schemas for new data types. We'll cover two important concepts: declaring schemas for primitive data types and type constructors.
Declaring Schemas for Primitive Data Types
A primitive data type represents simple values. To declare a schema for a primitive data type, like the File
type in TypeScript, we use the S.declare
constructor along with a type guard. Let's go through an example:
import { Schema } from "@effect/schema"
const FileFromSelf = Schema.declare(
(input: unknown): input is File => input instanceof File
)
const decode = Schema.decodeUnknownSync(FileFromSelf)
console.log(decode(new File([], "")))
decode(null)
As you can see, the error message describes what went wrong but doesn't provide much information about which schema caused the error ("Expected <declaration schema>"
). 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.
A "title" should be concise, while a "description" provides a more detailed explanation of the purpose of the data described by the schema.
import { Schema } from "@effect/schema"
const FileFromSelf = Schema.declare(
(input: unknown): input is File => input instanceof File,
{
identifier: "FileFromSelf",
description: "The `File` type in JavaScript"
}
)
const decode = Schema.decodeUnknownSync(FileFromSelf)
console.log(decode(new File([], "")))
decode(null)
Declaring Schemas for Type Constructors
Type constructors are generic types that take one or more types as arguments and return a new type. If you need to define a schema for a type constructor, you can use the S.declare
constructor. Let's illustrate this with a schema for ReadonlySet<A>
:
import { ParseResult, Schema } from "@effect/schema"
export const MyReadonlySet = <A, I, R>(
item: Schema.Schema<A, I, R>
): Schema.Schema<ReadonlySet<A>, ReadonlySet<I>, R> =>
Schema.declare(
[item],
{
decode: (item) => (input, parseOptions, ast) => {
if (input instanceof Set) {
const elements = ParseResult.decodeUnknown(Schema.Array(item))(
Array.from(input.values()),
parseOptions
)
return ParseResult.map(elements, (as): ReadonlySet<A> => new Set(as))
}
return ParseResult.fail(new ParseResult.Type(ast, input))
},
encode: (item) => (input, parseOptions, ast) => {
if (input instanceof Set) {
const elements = ParseResult.encodeUnknown(Schema.Array(item))(
Array.from(input.values()),
parseOptions
)
return ParseResult.map(elements, (is): ReadonlySet<I> => new Set(is))
}
return ParseResult.fail(new ParseResult.Type(ast, input))
}
},
{
description: `ReadonlySet<${Schema.format(item)}>`
}
)
const setOfNumbers = MyReadonlySet(Schema.NumberFromString)
const decode = Schema.decodeUnknownSync(setOfNumbers)
console.log(decode(new Set(["1", "2", "3"])))
decode(null)
decode(new Set(["1", null, "3"]))
[!WARNING]
The decoding and encoding functions cannot use context (the R
type parameter) and cannot use async effects.
Adding Annotations
When you define a new data type, some compilers like Arbitrary
or Pretty
may not know how to handle the newly defined data. For instance:
import { Arbitrary, Schema } from "@effect/schema"
const FileFromSelf = Schema.declare(
(input: unknown): input is File => input instanceof File,
{
identifier: "FileFromSelf"
}
)
const arb = Arbitrary.make(FileFromSelf)
In such cases, you need to provide annotations to ensure proper functionality:
import { Arbitrary, FastCheck, Pretty, Schema } from "@effect/schema"
const FileFromSelf = Schema.declare(
(input: unknown): input is File => input instanceof File,
{
identifier: "FileFromSelf",
arbitrary: () => (fc) =>
fc
.tuple(fc.string(), fc.string())
.map(([path, content]) => new File([content], path)),
pretty: () => (file) => `File(${file.name})`
}
)
const arb = Arbitrary.make(FileFromSelf)
const files = FastCheck.sample(arb, 2)
console.log(files)
const pretty = Pretty.make(FileFromSelf)
console.log(pretty(files[0]))
Transformations
Transformations are a crucial aspect of working with schemas, especially when you need to convert data from one type to another, such as parsing a string into a number or converting a date string into a Date
object.
transform
The transform
combinator is designed to facilitate these conversions by linking two schemas together: one for the input type and one for the output type.
The transform
combinator takes four key parameters:
- from: This is the source schema, denoted as
Schema<B, A, R1>
, where A
is the input type and B
is the intermediate type after initial validation. - to: This is the target schema, denoted as
Schema<D, C, R2>
, where C
is the transformed type from B
, and D
is the final output type. - decode: A function that transforms an intermediate value of type
B
into another value of type C
. - encode: A function that reverses the transformation, converting type
C
back to type B
.
The resulting schema from using transform
will be Schema<D, A, R1 | R2>
, indicating it integrates the dependencies and transformations specified in both the from
and to
schemas.
Flowchart Explanation
flowchart TD
schema1["from: Schema<B, A>"]
schema2["to: Schema<D, C>"]
schema1--"decode: B -> C"-->schema2
schema2--"encode: C -> B"-->schema1
This flowchart illustrates how the data flows through the transform
combinator, starting from the from
schema, passing through the transformation functions, and finally through the to
schema.
Practical Example: Doubling a Number
Here's how you might define a simple schema transformation that doubles an input number:
import { Schema } from "@effect/schema"
export const transformedSchema = Schema.transform(
Schema.Number,
Schema.Number,
{
strict: true,
decode: (n) => n * 2,
encode: (n) => n / 2
}
)
In this example, if you provide the input value 2
, the transformation schema will decode it to 4
and encode it back to 2
.
Here's a simple example of how you might use the transform
combinator to trim whitespace from string inputs:
import { Schema } from "@effect/schema"
export const transformedSchema = Schema.transform(
Schema.String,
Schema.String,
{
strict: true,
decode: (s) => s.trim(),
encode: (s) => s
}
)
In this example, the transform
function is used to create a schema that automatically trims leading and trailing whitespace from a string when decoding. During encoding, it simply returns the string as is, assuming it's already trimmed.
Example: Converting an array to a ReadonlySet
import { Schema } from "@effect/schema"
export const ReadonlySetFromArray = <A, I, R>(
itemSchema: Schema.Schema<A, I, R>
): Schema.Schema<ReadonlySet<A>, ReadonlyArray<I>, R> =>
Schema.transform(
Schema.Array(itemSchema),
Schema.ReadonlySetFromSelf(Schema.typeSchema(itemSchema)),
{
strict: true,
decode: (as) => new Set(as),
encode: (set) => Array.from(set.values())
}
)
[!WARNING]
Please note that to define the target schema, we used Schema.typeSchema(itemSchema)
. This is because the decoding/encoding of the elements is already handled by the from
schema, Schema.Array(itemSchema)
.
Improving the Transformation with a Filter
While the basic example ensures that strings are trimmed during the decoding process, it doesn't enforce that only trimmed strings are accepted or returned by the schema. To enhance this, you can restrict the target schema to only accept strings that are already trimmed:
import { Schema } from "@effect/schema"
export const transformedSchema = Schema.transform(
Schema.String,
Schema.String.pipe(Schema.filter((s) => s === s.trim())),
{
strict: true,
decode: (s) => s.trim(),
encode: (s) => s
}
)
In this improved example, the target schema is piped through a filter
function. This function checks that the string is equal to its trimmed version, effectively ensuring that only strings without leading or trailing whitespace are considered valid. This is particularly useful for maintaining data integrity and can help prevent errors or inconsistencies in data processing.
Non-strict option
Sometimes the strict type checking can impede certain operations where types might slightly deviate during the transformation process. For such cases, transform
provides an option, strict: false
, to relax type constraints and allow for more flexible data manipulation.
Example: Clamping Constructor
Let's consider the scenario where you need to define a constructor clamp
that ensures a number falls within a specific range. This function returns a schema that "clamps" a number to a specified minimum and maximum range:
import { Schema } from "@effect/schema"
import { Number } from "effect"
const clamp =
(minimum: number, maximum: number) =>
<A extends number, I, R>(self: Schema.Schema<A, I, R>) =>
Schema.transform(
self,
self.pipe(
Schema.typeSchema,
Schema.filter((a) => a <= minimum || a >= maximum)
),
{
strict: true,
decode: (a) => Number.clamp(a, { minimum, maximum }),
encode: (a) => a
}
)
In this code, Number.clamp
is a function that adjusts the given number to stay within the specified range. However, the return type of Number.clamp
may not strictly be of type A
but just a number
, which can lead to type mismatches according to TypeScript's strict type-checking.
There are two ways to resolve the type mismatch:
-
Using Type Assertion:
Adding a type cast can enforce the return type to be treated as type A
:
decode: (a) => Number.clamp(a, { minimum, maximum }) as A
-
Using the Non-Strict Option:
Setting strict: false
in the transformation options allows the schema to bypass some of TypeScript's type-checking rules, accommodating the type discrepancy:
import { Schema } from "@effect/schema"
import { Number } from "effect"
export const clamp =
(minimum: number, maximum: number) =>
<A extends number, I, R>(self: Schema.Schema<A, I, R>) =>
Schema.transform(
self,
self.pipe(
Schema.typeSchema,
Schema.filter((a) => a >= minimum && a <= maximum)
),
{
strict: false,
decode: (a) => Number.clamp(a, { minimum, maximum }),
encode: (a) => a
}
)
transformOrFail
In data transformation processes, handling transformations that may fail is crucial. While the transform
combinator is suitable for error-free transformations, the transformOrFail
combinator is designed for more complex scenarios where transformations can fail during the decoding or encoding stages.
The transformOrFail
combinator extends the capabilities of the transform
combinator by allowing for potential failures in the transformation functions. This combinator enables functions to return either a successful result or an error, making it particularly useful for validating and processing data that might not always conform to expected formats.
Error Handling with ParseResult
The transformOrFail
combinator utilizes the ParseResult
module to manage potential errors:
ParseResult.succeed
: This function is used to indicate a successful transformation, where no errors occurred.ParseResult.fail(issue)
: This function signals a failed transformation, creating a new ParseError
based on the provided ParseIssue
.
Additionally, the ParseError
module provides APIs for dealing with various types of parse issues, such as Declaration
, Refinement
, TupleType
, TypeLiteral
, Union
, Transformation
, Type
, and Forbidden
. These tools allow for detailed and specific error handling, enhancing the reliability of data processing operations.
Example: Converting a String to a Number
A common use case for transformOrFail
is converting string representations of numbers into actual numeric types. This scenario is typical when dealing with user inputs or data from external sources.
import { ParseResult, Schema } from "@effect/schema"
export const NumberFromString = Schema.transformOrFail(
Schema.String,
Schema.Number,
{
strict: true,
decode: (input, options, ast) => {
const parsed = parseFloat(input)
if (isNaN(parsed)) {
return ParseResult.fail(
new ParseResult.Type(ast, input, "Failed to convert string to number")
)
}
return ParseResult.succeed(parsed)
},
encode: (input, options, ast) => ParseResult.succeed(input.toString())
}
)
In this example:
- Decoding: Attempts to parse the input string into a number. If the parsing results in
NaN
(indicating that the string is not a valid number), it fails with a descriptive error. - Encoding: Converts the number back to a string, assuming that the input number is valid.
Both decode
and encode
functions not only receive the value to transform (input
), but also the parse options
that the user sets when using the resulting schema, and the ast
, which represents the AST
of the schema you're transforming.
Async Operations
In modern applications, especially those interacting with external APIs, you might need to transform data asynchronously
Example: Asynchronously Converting a String to a Number Using an API
Consider a situation where you need to validate a person's ID by fetching data from an external API. Here's how you can implement it:
import { ParseResult, Schema, TreeFormatter } from "@effect/schema"
import { Effect } from "effect"
const api = (url: string): Effect.Effect<unknown, Error> =>
Effect.tryPromise({
try: () =>
fetch(url).then((res) => {
if (res.ok) {
return res.json() as Promise<unknown>
}
throw new Error(String(res.status))
}),
catch: (e) => new Error(String(e))
})
const PeopleId = Schema.String.pipe(Schema.brand("PeopleId"))
const PeopleIdFromString = Schema.transformOrFail(Schema.String, PeopleId, {
strict: true,
decode: (s, _, ast) =>
Effect.mapBoth(api(`https://swapi.dev/api/people/${s}`), {
onFailure: (e) => new ParseResult.Type(ast, s, e.message),
onSuccess: () => s
}),
encode: ParseResult.succeed
})
const decode = (id: string) =>
Effect.mapError(Schema.decodeUnknown(PeopleIdFromString)(id), (e) =>
TreeFormatter.formatError(e)
)
Effect.runPromiseExit(decode("1")).then(console.log)
Effect.runPromiseExit(decode("fail")).then(console.log)
Declaring Dependencies
For more complex scenarios where your transformation might depend on external services like a fetching function, you can declare these dependencies explicitly. This approach ensures that your schema transformations are not only testable but also modular.
Example: Injecting Dependencies
Here's how to inject a fetch dependency into your transformation process:
import { ParseResult, Schema, TreeFormatter } from "@effect/schema"
import { Context, Effect, Layer } from "effect"
const Fetch = Context.GenericTag<"Fetch", typeof fetch>("Fetch")
const api = (url: string): Effect.Effect<unknown, Error, "Fetch"> =>
Fetch.pipe(
Effect.flatMap((fetch) =>
Effect.tryPromise({
try: () =>
fetch(url).then((res) => {
if (res.ok) {
return res.json() as Promise<unknown>
}
throw new Error(String(res.status))
}),
catch: (e) => new Error(String(e))
})
)
)
const PeopleId = Schema.String.pipe(Schema.brand("PeopleId"))
const PeopleIdFromString = Schema.transformOrFail(Schema.String, PeopleId, {
strict: true,
decode: (s, _, ast) =>
Effect.mapBoth(api(`https://swapi.dev/api/people/${s}`), {
onFailure: (e) => new ParseResult.Type(ast, s, e.message),
onSuccess: () => s
}),
encode: ParseResult.succeed
})
const decode = (id: string) =>
Effect.mapError(Schema.decodeUnknown(PeopleIdFromString)(id), (e) =>
TreeFormatter.formatError(e)
)
const FetchLive = Layer.succeed(Fetch, fetch)
Effect.runPromiseExit(decode("1").pipe(Effect.provide(FetchLive))).then(
console.log
)
Effect.runPromiseExit(decode("fail").pipe(Effect.provide(FetchLive))).then(
console.log
)
Effectful Filters
The filterEffect
function enhances the filter
functionality by allowing the integration of effects, thus enabling asynchronous or dynamic validation scenarios. This is particularly useful when validations need to perform operations that require side effects, such as network requests or database queries.
Example: Validating Usernames Asynchronously
import { Schema } from "@effect/schema"
import { Effect } from "effect"
async function validateUsername(username: string) {
return Promise.resolve(username === "gcanti")
}
const ValidUsername = Schema.String.pipe(
Schema.filterEffect((username) =>
Effect.promise(() =>
validateUsername(username).then((valid) => valid || "Invalid username")
)
)
).annotations({ identifier: "ValidUsername" })
Effect.runPromise(Schema.decodeUnknown(ValidUsername)("xxx")).then(console.log)
String Transformations
split
The split
combinator allows splitting a string into an array of strings.
import { Schema } from "@effect/schema"
const schema = Schema.split(",")
const decode = Schema.decodeUnknownSync(schema)
console.log(decode(""))
console.log(decode(","))
console.log(decode("a,"))
console.log(decode("a,b"))
Trim
The Trim
schema allows removing whitespaces from the beginning and end of a string.
import { Schema } from "@effect/schema"
const schema = Schema.Trim
const decode = Schema.decodeUnknownSync(schema)
console.log(decode("a"))
console.log(decode(" a"))
console.log(decode("a "))
console.log(decode(" a "))
Note. If you were looking for a combinator to check if a string is trimmed, check out the trimmed
filter.
Lowercase
The Lowercase
schema converts a string to lowercase.
import { Schema } from "@effect/schema"
const decode = Schema.decodeUnknownSync(Schema.Lowercase)
console.log(decode("A"))
console.log(decode(" AB"))
console.log(decode("Ab "))
console.log(decode(" ABc "))
Note. If you were looking for a combinator to check if a string is lowercased, check out the Lowercased
schema or the lowercased
filter.
Uppercase
The Uppercase
schema converts a string to uppercase.
import { Schema } from "@effect/schema"
const decode = Schema.decodeUnknownSync(Schema.Uppercase)
console.log(decode("a"))
console.log(decode(" ab"))
console.log(decode("aB "))
console.log(decode(" abC "))
Note. If you were looking for a combinator to check if a string is uppercased, check out the Uppercased
schema or the uppercased
filter.
Capitalize
The Capitalize
schema converts a string to capitalized one.
import { Schema } from "@effect/schema"
const decode = Schema.decodeUnknownSync(Schema.Capitalize)
console.log(decode("aa"))
console.log(decode(" ab"))
console.log(decode("aB "))
console.log(decode(" abC "))
Note. If you were looking for a combinator to check if a string is capitalized, check out the Capitalized
schema or the capitalized
filter.
Uncapitalize
The Uncapitalize
schema converts a string to uncapitalized one.
import { Schema } from "@effect/schema"
const decode = Schema.decodeUnknownSync(Schema.Uncapitalize)
console.log(decode("AA"))
console.log(decode(" AB"))
console.log(decode("Ab "))
console.log(decode(" AbC "))
Note. If you were looking for a combinator to check if a string is uncapitalized, check out the Uncapitalized
schema or the uncapitalized
filter.
parseJson
The parseJson
constructor offers a method to convert JSON strings into the unknown
type using the underlying functionality of JSON.parse
. It also employs JSON.stringify
for encoding.
import { Schema } from "@effect/schema"
const schema = Schema.parseJson()
const decode = Schema.decodeUnknownSync(schema)
console.log(decode("{}"))
console.log(decode(`{"a":"b"}`))
decode("")
Additionally, you can refine the parsing result by providing a schema to the parseJson
constructor:
import { Schema } from "@effect/schema"
const schema = Schema.parseJson(Schema.Struct({ a: Schema.Number }))
In this example, we've used parseJson
with a struct schema to ensure that the parsed result has a specific structure, including an object with a numeric property "a". This helps in handling JSON data with predefined shapes.
StringFromBase64
Decodes a base64 (RFC4648) encoded string into a UTF-8 string.
import { Schema } from "@effect/schema"
const decode = Schema.decodeUnknownSync(Schema.StringFromBase64)
console.log(decode("Zm9vYmFy"))
StringFromBase64Url
Decodes a base64 (URL) encoded string into a UTF-8 string.
import { Schema } from "@effect/schema"
const decode = Schema.decodeUnknownSync(Schema.StringFromBase64Url)
console.log(decode("Zm9vYmFy"))
StringFromHex
Decodes a hex encoded string into a UTF-8 string.
import { Schema } from "@effect/schema"
import * as assert from "node:assert"
const decode = Schema.decodeUnknownSync(Schema.StringFromHex)
const decoder = new TextDecoder("utf-8")
assert.deepStrictEqual(
decode("0001020304050607"),
decoder.decode(Uint8Array.from([0, 1, 2, 3, 4, 5, 6, 7]))
)
Number Transformations
NumberFromString
Transforms a string
into a number
by parsing the string using parseFloat
.
The following special string values are supported: "NaN", "Infinity", "-Infinity".
import { Schema } from "@effect/schema"
const schema = Schema.NumberFromString
const decode = Schema.decodeUnknownSync(schema)
console.log(decode("1"))
console.log(decode("-1"))
console.log(decode("1.5"))
console.log(decode("NaN"))
console.log(decode("Infinity"))
console.log(decode("-Infinity"))
decode("a")
clamp
Clamps a number
between a minimum and a maximum value.
import { Schema } from "@effect/schema"
const schema = Schema.Number.pipe(Schema.clamp(-1, 1))
const decode = Schema.decodeUnknownSync(schema)
console.log(decode(-3))
console.log(decode(0))
console.log(decode(3))
parseNumber
Transforms a string
into a number
by parsing the string using the parse
function of the effect/Number
module.
It returns an error if the value can't be converted (for example when non-numeric characters are provided).
The following special string values are supported: "NaN", "Infinity", "-Infinity".
import { Schema } from "@effect/schema"
const schema = Schema.String.pipe(Schema.parseNumber)
const decode = Schema.decodeUnknownSync(schema)
console.log(decode("1"))
console.log(decode("Infinity"))
console.log(decode("NaN"))
console.log(decode("-"))
Boolean Transformations
Not
Negates a boolean value.
import { Schema } from "@effect/schema"
const schema = Schema.Not
const decode = Schema.decodeUnknownSync(schema)
console.log(decode(true))
console.log(decode(false))
Symbol transformations
Symbol
Transforms a string
into a symbol
by parsing the string using Symbol.for
.
import { Schema } from "@effect/schema"
const schema = Schema.Symbol
const decode = Schema.decodeUnknownSync(schema)
console.log(decode("a"))
BigInt transformations
BigInt
Transforms a string
into a BigInt
by parsing the string using BigInt
.
import { Schema } from "@effect/schema"
const schema = Schema.BigInt
const decode = Schema.decodeUnknownSync(schema)
console.log(decode("1"))
console.log(decode("-1"))
decode("a")
decode("1.5")
decode("NaN")
decode("Infinity")
decode("-Infinity")
BigIntFromNumber
Transforms a number
into a BigInt
by parsing the number using BigInt
.
import { Schema } from "@effect/schema"
const schema = Schema.BigIntFromNumber
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode(1))
console.log(decode(-1))
console.log(encode(1n))
console.log(encode(-1n))
decode(1.5)
decode(NaN)
decode(Infinity)
decode(-Infinity)
encode(BigInt(Number.MAX_SAFE_INTEGER) + 1n)
encode(BigInt(Number.MIN_SAFE_INTEGER) - 1n)
clamp
Clamps a BigInt
between a minimum and a maximum value.
import { Schema } from "@effect/schema"
const schema = Schema.BigIntFromSelf.pipe(Schema.clampBigInt(-1n, 1n))
const decode = Schema.decodeUnknownSync(schema)
console.log(decode(-3n))
console.log(decode(0n))
console.log(decode(3n))
Date transformations
Date
Transforms a string
into a valid Date
, ensuring that invalid dates, such as new Date("Invalid Date")
, are rejected.
import { Schema } from "@effect/schema"
const schema = Schema.Date
const decode = Schema.decodeUnknownSync(schema)
console.log(decode("1970-01-01T00:00:00.000Z"))
decode("a")
const validate = Schema.validateSync(schema)
console.log(validate(new Date(0)))
validate(new Date("Invalid Date"))
BigDecimal Transformations
BigDecimal
Transforms a string
into a BigDecimal
.
import { Schema } from "@effect/schema"
const schema = Schema.BigDecimal
const decode = Schema.decodeUnknownSync(schema)
console.log(decode(".124"))
BigDecimalFromNumber
Transforms a number
into a BigDecimal
.
[!WARNING]
Warning: When encoding, this Schema will produce incorrect results if the BigDecimal exceeds the 64-bit range of a number.
import { Schema } from "@effect/schema"
const schema = Schema.BigDecimalFromNumber
const decode = Schema.decodeUnknownSync(schema)
console.log(decode(0.111))
clampBigDecimal
Clamps a BigDecimal
between a minimum and a maximum value.
import { Schema } from "@effect/schema"
import { BigDecimal } from "effect"
const schema = Schema.BigDecimal.pipe(
Schema.clampBigDecimal(BigDecimal.fromNumber(-1), BigDecimal.fromNumber(1))
)
const decode = Schema.decodeUnknownSync(schema)
console.log(decode("-2"))
console.log(decode("0"))
console.log(decode("3"))
Advanced Usage
Annotations
One of the fundamental requirements in the design of @effect/schema
is that it is extensible and customizable. Customizations are achieved through "annotations". Each node contained in the AST of @effect/schema/AST
contains an annotations: Record<symbol, unknown>
field that can be used to attach additional information to the schema.
You can manage these annotations using the annotations
method.
Let's see some examples:
import { Schema } from "@effect/schema"
const Password =
Schema.String
.annotations({ message: () => "not a string" })
.pipe(
Schema.nonEmptyString({ message: () => "required" }),
Schema.maxLength(10, { message: (s) => `${s} is too long` })
)
.annotations({
identifier: "Password",
title: "password",
description:
"A password is a string of characters used to verify the identity of a user during the authentication process",
examples: ["1Ki77y", "jelly22fi$h"],
documentation: `jsDoc documentation...`
})
The example shows some built-in combinators to add meta information, but users can easily add their own meta information by defining a custom annotation.
Here's an example of how to add a deprecated
annotation:
import { AST, Schema } from "@effect/schema"
const DeprecatedId = Symbol.for(
"some/unique/identifier/for/the/custom/annotation"
)
const deprecated = <A, I, R>(
self: Schema.Schema<A, I, R>
): Schema.Schema<A, I, R> =>
Schema.make(AST.annotations(self.ast, { [DeprecatedId]: true }))
const schema = deprecated(Schema.String)
console.log(schema)
Annotations can be read using the getAnnotation
helper, here's an example:
import { AST, Schema } from "@effect/schema"
import { Option } from "effect"
const DeprecatedId = Symbol.for(
"some/unique/identifier/for/the/custom/annotation"
)
const deprecated = <A, I, R>(
self: Schema.Schema<A, I, R>
): Schema.Schema<A, I, R> =>
Schema.make(AST.annotations(self.ast, { [DeprecatedId]: true }))
const schema = deprecated(Schema.String)
const isDeprecated = <A, I, R>(schema: Schema.Schema<A, I, R>): boolean =>
AST.getAnnotation<boolean>(DeprecatedId)(schema.ast).pipe(
Option.getOrElse(() => false)
)
console.log(isDeprecated(Schema.String))
console.log(isDeprecated(schema))
Recursive Schemas
The 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.
import { Schema } from "@effect/schema"
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)
)
})
[!NOTE]
It is necessary to define the Category
type and add an explicit type annotation (const Category: S.Schema<Category>
) because otherwise TypeScript would struggle to infer types correctly. Without this annotation, you might encounter the error message: "'Category' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.ts(7022)"
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.
import { Schema } from "@effect/schema"
const fields = {
name: Schema.String
}
interface Category extends Schema.Struct.Type<typeof fields> {
readonly subcategories: ReadonlyArray<Category>
}
const Category = Schema.Struct({
...fields,
subcategories: Schema.Array(
Schema.suspend((): Schema.Schema<Category> => Category)
)
})
Mutually Recursive Schemas
Here's an example of two mutually recursive schemas, Expression
and Operation
, that represent a simple arithmetic expression tree.
import { Schema } from "@effect/schema"
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.
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/schema"
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(
Schema.suspend((): Schema.Schema<Category> => Category)
)
})
This error occurs because the explicit annotation const Category: S.Schema<Category>
is no longer sufficient and needs to be adjusted by explicitly adding the Encoded
type:
import { Schema } from "@effect/schema"
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)
)
})
Error messages
Default Error Messages
By default, when a parsing error occurs, the system automatically generates an informative message based on the schema's structure and the nature of the error. For example, if a required property is missing or a data type does not match, the error message will clearly state the expectation versus the actual input.
Type Mismatch Example
import { Schema } from "@effect/schema"
const schema = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
Schema.decodeUnknownSync(schema)(null)
Missing Properties Example
import { Schema } from "@effect/schema"
const schema = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
Schema.decodeUnknownSync(schema)({}, { errors: "all" })
Incorrect Property Type Example
import { Schema } from "@effect/schema"
const schema = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
Schema.decodeUnknownSync(schema)({ name: null, age: "age" }, { errors: "all" })
Enhancing Clarity in Error Messages with Identifiers
In scenarios where a schema has multiple fields or nested structures, the default error messages can become overly complex and verbose. To address this, you can enhance the clarity and brevity of these messages by utilizing annotations such as identifier
, title
, and description
.
Incorporating an identifier
annotation into your schema allows you to customize the error messages, making them more succinct and directly relevant to the specific part of the schema that triggered the error. Here's how you can apply this in practice:
import { Schema } from "@effect/schema"
const Name = Schema.String.annotations({ identifier: "Name" })
const Age = Schema.Number.annotations({ identifier: "Age" })
const Person = Schema.Struct({
name: Name,
age: Age
}).annotations({ identifier: "Person" })
Schema.decodeUnknownSync(Person)(null)
Schema.decodeUnknownSync(Person)({}, { errors: "all" })
Schema.decodeUnknownSync(Person)({ name: null, age: null }, { errors: "all" })
Refinements
When a refinement fails, the default error message indicates whether the failure occurred in the "from" part or within the predicate defining the refinement:
import { Schema } from "@effect/schema"
const Name = Schema.NonEmptyString.annotations({ identifier: "Name" })
const Age = Schema.Positive.pipe(Schema.int({ identifier: "Age" }))
const Person = Schema.Struct({
name: Name,
age: Age
}).annotations({ identifier: "Person" })
Schema.decodeUnknownSync(Person)({ name: null, age: 18 })
Schema.decodeUnknownSync(Person)({ name: "", age: 18 })
In the first example, the error message indicates a "from side" refinement failure in the name
property, specifying that a string was expected but received null
. In the second example, a "predicate" refinement failure is reported, indicating that a non-empty string was expected for name
but an empty string was provided.
Transformations
Transformations between different types or formats can occasionally result in errors. The system provides a structured error message to specify where the error occurred:
- Encoded Side Failure: Errors on this side typically indicate that the input to the transformation does not match the expected initial type or format. For example, receiving a
null
when a string
is expected. - Transformation Process Failure: This type of error arises when the transformation logic itself fails, such as when the input does not meet the criteria specified within the transformation functions.
- Type Side Failure: Occurs when the output of a transformation does not meet the schema requirements on the decoded side. This can happen if the transformed value fails subsequent validations or conditions.
import { ParseResult, Schema } from "@effect/schema"
const schema = Schema.transformOrFail(
Schema.String,
Schema.String.pipe(Schema.minLength(2)),
{
strict: true,
decode: (s, _, ast) =>
s.length > 0
? ParseResult.succeed(s)
: ParseResult.fail(new ParseResult.Type(ast, s)),
encode: ParseResult.succeed
}
)
Schema.decodeUnknownSync(schema)(null)
Schema.decodeUnknownSync(schema)("")
Schema.decodeUnknownSync(schema)("a")
Custom Error Messages
You have the capability to define custom error messages specifically tailored for different parts of your schema using the message
annotation. This allows developers to provide more context-specific feedback which can improve the debugging and validation processes.
type MessageAnnotation = (issue: ParseIssue) =>
| string
| Effect<string>
| {
readonly message: string | Effect<string>
readonly override: boolean
}
- String: A straightforward message that describes the error.
- Effect: Allows for dynamic error messages that might depend on synchronous processes or optional dependencies.
- Object with
message
and override
: Allows you to define a specific error message along with a boolean flag (override
). This flag determines if the custom message should supersede any default or nested custom messages, providing precise control over the error output displayed to users.
Here's a simple example of how to set a custom message for the built-in String
schema:
import { Schema } from "@effect/schema"
const MyString = Schema.String.annotations({
message: () => "my custom message"
})
Schema.decodeUnknownSync(MyString)(null)
General Guidelines for Messages
The general logic followed to determine the messages is as follows:
-
If no custom messages are set, the default message related to the innermost schema where the operation (i.e., decoding or encoding) failed is used.
-
If custom messages are set, then the message corresponding to the first failed schema is used, starting from the innermost schema to the outermost. However, if the failing schema does not have a custom message, then the default message is used.
-
As an opt-in feature, you can override guideline 2 by setting the overwrite
flag to true
. This allows the custom message to take precedence over all other custom messages from inner schemas. This is to address the scenario where a user wants to define a single cumulative custom message describing the properties that a valid value must have and does not want to see default messages.
Let's see some practical examples.
Scalar Schemas
import { Schema } from "@effect/schema"
const MyString = Schema.String.annotations({
message: () => "my custom message"
})
const decode = Schema.decodeUnknownSync(MyString)
try {
decode(null)
} catch (e: any) {
console.log(e.message)
}
Refinements
This example demonstrates setting a custom message on the last refinement in a chain of refinements. As you can see, the custom message is only used if the refinement related to maxLength
fails; otherwise, default messages are used.
import { Schema } from "@effect/schema"
const MyString = Schema.String.pipe(
Schema.minLength(1),
Schema.maxLength(2)
).annotations({
message: () => "my custom message"
})
const decode = Schema.decodeUnknownSync(MyString)
try {
decode(null)
} catch (e: any) {
console.log(e.message)
}
try {
decode("")
} catch (e: any) {
console.log(e.message)
}
try {
decode("abc")
} catch (e: any) {
console.log(e.message)
}
When setting multiple override messages, the one corresponding to the first failed predicate is used, starting from the innermost refinement to the outermost:
import { Schema } from "@effect/schema"
const MyString = Schema.String
.annotations({ message: () => "String custom message" })
.pipe(
Schema.minLength(1, { message: () => "minLength custom message" }),
Schema.maxLength(2, { message: () => "maxLength custom message" })
)
const decode = Schema.decodeUnknownSync(MyString)
try {
decode(null)
} catch (e: any) {
console.log(e.message)
}
try {
decode("")
} catch (e: any) {
console.log(e.message)
}
try {
decode("abc")
} catch (e: any) {
console.log(e.message)
}
You have the option to change the default behavior by setting the override
flag to true
. This is useful when you want to create a single comprehensive custom message that describes the required properties of a valid value without displaying default messages.
import { Schema } from "@effect/schema"
const MyString = Schema.String.pipe(
Schema.minLength(1),
Schema.maxLength(2)
).annotations({
message: () => ({ message: "my custom message", override: true })
})
const decode = Schema.decodeUnknownSync(MyString)
try {
decode(null)
} catch (e: any) {
console.log(e.message)
}
try {
decode("")
} catch (e: any) {
console.log(e.message)
}
try {
decode("abc")
} catch (e: any) {
console.log(e.message)
}
Transformations
In this example, IntFromString
is a transformation schema that converts strings to integers. It applies specific validation messages based on different scenarios.
import { ParseResult, Schema } from "@effect/schema"
const IntFromString = Schema.transformOrFail(
Schema.String.annotations({ message: () => "please enter a string" }),
Schema.Int.annotations({ message: () => "please enter an integer" }),
{
strict: true,
decode: (s, _, ast) => {
const n = Number(s)
return Number.isNaN(n)
? ParseResult.fail(new ParseResult.Type(ast, s))
: ParseResult.succeed(n)
},
encode: (n) => ParseResult.succeed(String(n))
}
)
.annotations({ message: () => "please enter a parseable string" })
const decode = Schema.decodeUnknownSync(IntFromString)
try {
decode(null)
} catch (e: any) {
console.log(e.message)
}
try {
decode("1.2")
} catch (e: any) {
console.log(e.message)
}
try {
decode("not a number")
} catch (e: any) {
console.log(e.message)
}
Compound Schemas
The custom message system becomes especially handy when dealing with complex schemas, unlike simple scalar values like string
or number
. For instance, consider a schema comprising nested structures, such as a struct containing an array of other structs. Let's explore an example demonstrating the advantage of default messages in handling decoding errors within such nested structures:
import { Schema } from "@effect/schema"
import { pipe } from "effect"
const schema = Schema.Struct({
outcomes: pipe(
Schema.Array(
Schema.Struct({
id: Schema.String,
text: pipe(
Schema.String.annotations({
message: () => "error_invalid_outcome_type"
}),
Schema.minLength(1, { message: () => "error_required_field" }),
Schema.maxLength(50, { message: () => "error_max_length_field" })
)
})
),
Schema.minItems(1, { message: () => "error_min_length_field" })
)
})
Schema.decodeUnknownSync(schema, { errors: "all" })({
outcomes: []
})
Schema.decodeUnknownSync(schema, { errors: "all" })({
outcomes: [
{ id: "1", text: "" },
{ id: "2", text: "this one is valid" },
{ id: "3", text: "1234567890".repeat(6) }
]
})
Effectful messages
Messages are not only of type string
but can return an Effect
so that they can have dependencies (for example, from an internationalization service). Let's see the outline of a similar situation with a very simplified example for demonstration purposes:
import { Schema, TreeFormatter } from "@effect/schema"
import { Context, Effect, Either, Option } from "effect"
class Messages extends Context.Tag("Messages")<
Messages,
{
NonEmpty: string
}
>() {}
const Name = Schema.NonEmptyString.annotations({
message: () =>
Effect.gen(function* (_) {
const service = yield* _(Effect.serviceOption(Messages))
return Option.match(service, {
onNone: () => "Invalid string",
onSome: (messages) => messages.NonEmpty
})
})
})
Schema.decodeUnknownSync(Name)("")
const result = Schema.decodeUnknownEither(Name)("").pipe(
Either.mapLeft((error) =>
TreeFormatter.formatError(error).pipe(
Effect.provideService(Messages, { NonEmpty: "should be non empty" }),
Effect.runSync
)
)
)
console.log(result)
Missing messages
You can provide custom messages for missing fields or elements using the missingMessage
annotation.
Example (missing field)
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.propertySignature(Schema.String).annotations({
missingMessage: () => "Name is required"
})
})
Schema.decodeUnknownSync(Person)({})
Example (missing element)
import { Schema } from "@effect/schema"
const Point = Schema.Tuple(
Schema.element(Schema.Number).annotations({
missingMessage: () => "X coordinate is required"
}),
Schema.element(Schema.Number).annotations({
missingMessage: () => "Y coordinate is required"
})
)
Schema.decodeUnknownSync(Point)([], { errors: "all" })
Classes
When working with schemas, you have a choice beyond the S.Struct
constructor. You can leverage the power of classes through the Class
utility, which comes with its own set of advantages tailored to common use cases:
Classes offer several features that simplify the schema creation process:
- All-in-One Definition: With classes, you can define both a schema and an opaque type simultaneously.
- Shared Functionality: You can incorporate shared functionality using class methods or getters.
- Value Hashing and Equality: Utilize the built-in capability for checking value equality and applying hashing (thanks to
Class
implementing Data.Class).
Definition
To define a Class
in @effect/schema
, you need to provide:
- The type of the class being created.
- A unique identifier for the class.
- The desired fields, or any schema that has an exposed
fields
property.
import { Schema } from "@effect/schema"
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.String.pipe(Schema.nonEmptyString())
}) {}
In this setup, Person
is a class where id
is a number and name
is a non-empty string. The constructor for the class creates instances with these specified properties.
Classes Without Arguments
If your schema does not require any fields, you can define a class with an empty object:
import { Schema } from "@effect/schema"
class NoArgs extends Schema.Class<NoArgs>("NoArgs")({}) {}
const noargs = new NoArgs()
Class Constructor as a Validator
When you define a class using Schema.Class
, the constructor automatically checks that the provided properties adhere to the schema's rules. Here's how you can define and instantiate a Person
class:
import { Schema } from "@effect/schema"
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.String.pipe(Schema.nonEmptyString())
}) {}
const john = new Person({ id: 1, name: "John" })
john.id
john.name
This ensures that each property of the Person
instance, like id
and name
, meets the conditions specified in the schema, such as id
being a number and name
being a non-empty string.
If an instance is created with invalid properties, the constructor throws an error detailing what went wrong:
try {
new Person({ id: 1, name: "" })
} catch (error) {
console.error(error)
}
This error message clearly states that the name
field failed the non-empty string predicate, providing precise feedback on why the validation failed.
There are scenarios where you might want to bypass validation during instantiation. Although not typically recommended, @effect/schema
allows for this flexibility:
const john = new Person({ id: 1, name: "" }, true)
new Person({ id: 1, name: "" }, { disableValidation: true })
Hashing and Equality
Thanks to the implementation of Data.Class
, instances of your classes automatically support the Equal
trait, which allows for easy comparison:
import { Schema } from "@effect/schema"
import { Equal } from "effect"
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.String.pipe(Schema.nonEmptyString())
}) {}
const john1 = new Person({ id: 1, name: "John" })
const john2 = new Person({ id: 1, name: "John" })
console.log(Equal.equals(john1, john2))
However, be aware that the Equal
trait checks for equality only at the first level. If, for instance, a field is an array, the returned instances will not be considered equal:
import { Schema } from "@effect/schema"
import { Equal } from "effect"
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.String.pipe(Schema.nonEmptyString()),
hobbies: Schema.Array(Schema.String)
}) {}
const john1 = new Person({
id: 1,
name: "John",
hobbies: ["reading", "coding"]
})
const john2 = new Person({
id: 1,
name: "John",
hobbies: ["reading", "coding"]
})
console.log(Equal.equals(john1, john2))
To ensure deep equality for arrays, use Schema.Data
combined with Data.array
:
import { Schema } from "@effect/schema"
import { Data, Equal } from "effect"
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.String.pipe(Schema.nonEmptyString()),
hobbies: Schema.Data(Schema.Array(Schema.String))
}) {}
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"])
})
console.log(Equal.equals(john1, john2))
Custom Getters and Methods
You have the flexibility to enhance schema classes with custom getters and methods.
Let's look at how you can add a custom getter to a class:
import { Schema } from "@effect/schema"
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.String.pipe(Schema.nonEmptyString())
}) {
get upperName() {
return this.name.toUpperCase()
}
}
const john = new Person({ id: 1, name: "John" })
console.log(john.upperName)
Using Classes as Schemas
When you define a class using Schema.Class
, it not only creates a new class but also treats this class as a schema. This means the class can be utilized wherever a schema is expected.
import { Schema } from "@effect/schema"
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.String.pipe(Schema.nonEmptyString())
}) {}
const Persons = Schema.Array(Person)
The .fields
Property
The class also includes a .fields
static property, which outlines the fields defined during the class creation.
Person.fields
Recursive Schemas
The 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.
import { Schema } from "@effect/schema"
class Category extends Schema.Class<Category>("Category")({
name: Schema.String,
subcategories: Schema.Array(
Schema.suspend((): Schema.Schema<Category> => Category)
)
}) {}
[!NOTE]
It is necessary to add an explicit type annotation (S.suspend((): S.Schema<Category> => Category
) because otherwise TypeScript would struggle to infer types correctly. Without this annotation, you might encounter the error message: "Type 'typeof Category' is missing the following properties from type 'Schema<unknown, unknown, unknown>': ast, annotations, [TypeId], pipets(2739)"
Mutually Recursive Schemas
Here's an example of two mutually recursive schemas, Expression
and Operation
, that represent a simple arithmetic expression tree.
import { Schema } from "@effect/schema"
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 a recursive schema where the Encoded
type differs from the Type
type adds another layer of complexity. In such cases, we need to define an interface for the Encoded
type.
Let's consider an example: suppose we want to add an id
field to the Category
schema, where the schema for id
is NumberFromString
. It's important to note that NumberFromString
is a schema that transforms a string into a number, so the Type
and Encoded
types of NumberFromString
differ, being number
and string
respectively. When we add this field to the Category
schema, TypeScript raises an error:
import { Schema } from "@effect/schema"
class Category extends Schema.Class<Category>("Category")({
id: Schema.NumberFromString,
name: Schema.String,
subcategories: Schema.Array(
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:
import { Schema } from "@effect/schema"
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.
import { Schema } from "@effect/schema"
const fields = {
id: Schema.NumberFromString,
name: Schema.String
}
interface CategoryEncoded extends Schema.Struct.Encoded<typeof fields> {
readonly subcategories: ReadonlyArray<CategoryEncoded>
}
class Category extends Schema.Class<Category>("Category")({
...fields,
subcategories: Schema.Array(
Schema.suspend((): Schema.Schema<Category, CategoryEncoded> => Category)
)
}) {}
Tagged Class variants
You can also create classes that extend TaggedClass
& TaggedError
from the effect/Data
module:
import { Schema } from "@effect/schema"
class TaggedPerson extends Schema.TaggedClass<TaggedPerson>()("TaggedPerson", {
name: Schema.String
}) {}
class HttpError extends Schema.TaggedError<HttpError>()("HttpError", {
status: Schema.Number
}) {}
const joe = new TaggedPerson({ name: "Joe" })
console.log(joe._tag)
const error = new HttpError({ status: 404 })
console.log(error._tag)
console.log(error.stack)
Extending existing Classes
In situations where you need to augment your existing class with more fields, the built-in extend
utility comes in handy:
import { Schema } from "@effect/schema"
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.String.pipe(Schema.nonEmptyString())
}) {
get upperName() {
return this.name.toUpperCase()
}
}
class PersonWithAge extends Person.extend<PersonWithAge>("PersonWithAge")({
age: Schema.Number
}) {
get isAdult() {
return this.age >= 18
}
}
Transformations
You have the option to enhance a class with (effectful) transformations. This becomes valuable when you want to enrich or validate an entity sourced from a data store.
import { Schema } from "@effect/schema"
import { Effect, Option } from "effect"
export class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.String
}) {}
console.log(Schema.decodeUnknownSync(Person)({ id: 1, name: "name" }))
function getAge(id: number): Effect.Effect<number, Error> {
return Effect.succeed(id + 2)
}
export class PersonWithTransform extends Person.transformOrFail<PersonWithTransform>(
"PersonWithTransform"
)(
{
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.id, e.message),
onSuccess: (age) => ({ ...input, age: Option.some(age) })
}),
encode: ParseResult.succeed
}
) {}
Schema.decodeUnknownPromise(PersonWithTransform)({ id: 1, name: "name" }).then(
console.log
)
export class 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),
onSuccess: (age) => (age > 18 ? { ...input, age } : { ...input })
}),
encode: ParseResult.succeed
}
) {}
Schema.decodeUnknownPromise(PersonWithTransformFrom)({
id: 1,
name: "name"
}).then(console.log)
The decision of which API to use, either transformOrFail
or transformOrFailFrom
, depends on when you wish to execute the transformation:
-
Using transformOrFail
:
- The transformation occurs at the end of the process.
- It expects you to provide a value of type
{ age: Option<number> }
. - After processing the initial input, the new transformation comes into play, and you need to ensure the final output adheres to the specified structure.
-
Using transformOrFailFrom
:
- The new transformation starts as soon as the initial input is handled.
- You should provide a value
{ age?: number }
. - Based on this fresh input, the subsequent transformation
{ age: S.optionalToOption(S.Number, { exact: true }) }
is executed. - This approach allows for immediate handling of the input, potentially influencing the subsequent transformations.
Default Constructors
When dealing with data, creating values that match a specific schema is crucial. To simplify this process, we've introduced default constructors for various types of schemas: Struct
s, Record
s, filter
s, and brand
s. Let's dive into each of them with some examples to understand better how they work.
[!NOTE]
Default constructors associated with a schema Schema<A, I, R>
are specifically related to the A
type, not the I
type.
Example (Struct
)
import { Schema } from "@effect/schema"
const Struct = Schema.Struct({
name: Schema.NonEmptyString
})
Struct.make({ name: "a" })
Struct.make({ name: "" })
There are scenarios where you might want to bypass validation during instantiation. Although not typically recommended, @effect/schema
allows for this flexibility:
Struct.make({ name: "" }, true)
Struct.make({ name: "" }, { disableValidation: true })
Example (Record
)
import { Schema } from "@effect/schema"
const Record = Schema.Record({
key: Schema.String,
value: Schema.NonEmptyString
})
Record.make({ a: "a", b: "b" })
Record.make({ a: "a", b: "" })
Record.make({ a: "a", b: "" }, { disableValidation: true })
Example (filter
)
import { Schema } from "@effect/schema"
const MyNumber = Schema.Number.pipe(Schema.between(1, 10))
const n = MyNumber.make(5)
MyNumber.make(20)
MyNumber.make(20, { disableValidation: true })
Example (brand
)
import { Schema } from "@effect/schema"
const BrandedNumberSchema = Schema.Number.pipe(
Schema.between(1, 10),
Schema.brand("MyNumber")
)
const n = BrandedNumberSchema.make(5)
BrandedNumberSchema.make(20)
BrandedNumberSchema.make(20, { disableValidation: true })
When utilizing our default constructors, it's important to grasp the type of value they generate. In the BrandedNumberSchema
example, the return type of the constructor is number & Brand<"MyNumber">
, indicating that the resulting value is a number with the added branding "MyNumber".
This differs from the filter example where the return type is simply number
. The branding offers additional insights about the type, facilitating the identification and manipulation of your data.
Note that default constructors are "unsafe" in the sense that if the input does not conform to the schema, the constructor throws an error containing a description of what is wrong. This is because the goal of default constructors is to provide a quick way to create compliant values (for example, for writing tests or configurations, or in any situation where it is assumed that the input passed to the constructors is valid and the opposite situation is exceptional). To have a "safe" constructor, you can use Schema.validateEither
:
import { Schema } from "@effect/schema"
const MyNumber = Schema.Number.pipe(Schema.between(1, 10))
const ctor = Schema.validateEither(MyNumber)
console.log(ctor(5))
console.log(ctor(20))
Introduction to Setting Default Values
When constructing objects, it's common to want to assign default values to certain fields to simplify the creation of new instances. Our new withConstructorDefault
combinator allows you to effortlessly manage the optionality of a field in your default constructor.
Example Without Default
import { Schema } from "@effect/schema"
const PersonSchema = Schema.Struct({
name: Schema.NonEmptyString,
age: Schema.Number
})
PersonSchema.make({ name: "John", age: 30 })
Example With Default
import { Schema } from "@effect/schema"
const PersonSchema = Schema.Struct({
name: Schema.NonEmptyString,
age: Schema.Number.pipe(
Schema.propertySignature,
Schema.withConstructorDefault(() => 0)
)
})
console.log(PersonSchema.make({ name: "John" }))
In the second example, notice how the age
field is now optional and defaults to 0
when not provided.
Defaults are lazily evaluated, meaning that a new instance of the default is generated every time the constructor is called:
import { Schema } from "@effect/schema"
const PersonSchema = Schema.Struct({
name: Schema.NonEmptyString,
age: Schema.Number.pipe(
Schema.propertySignature,
Schema.withConstructorDefault(() => 0)
),
timestamp: Schema.Number.pipe(
Schema.propertySignature,
Schema.withConstructorDefault(() => new Date().getTime())
)
})
console.log(PersonSchema.make({ name: "name1" }))
console.log(PersonSchema.make({ name: "name2" }))
Note how the timestamp
field varies.
Default values are also "portable", meaning that if you reuse the same property signature in another schema, the default is carried over:
import { Schema } from "@effect/schema"
const PersonSchema = Schema.Struct({
name: Schema.NonEmptyString,
age: Schema.Number.pipe(
Schema.propertySignature,
Schema.withConstructorDefault(() => 0)
),
timestamp: Schema.Number.pipe(
Schema.propertySignature,
Schema.withConstructorDefault(() => new Date().getTime())
)
})
const AnotherSchema = Schema.Struct({
foo: Schema.String,
age: PersonSchema.fields.age
})
console.log(AnotherSchema.make({ foo: "bar" }))
Defaults can also be applied using the Class
API:
import { Schema } from "@effect/schema"
class Person extends Schema.Class<Person>("Person")({
name: Schema.NonEmptyString,
age: Schema.Number.pipe(
Schema.propertySignature,
Schema.withConstructorDefault(() => 0)
),
timestamp: Schema.Number.pipe(
Schema.propertySignature,
Schema.withConstructorDefault(() => new Date().getTime())
)
}) {}
console.log(new Person({ name: "name1" }))
console.log(new Person({ name: "name2" }))
API Interfaces
What's an API Interface?
An "API Interface" is an interface
specifically defined for a schema exported from @effect/schema
or for a particular API exported from @effect/schema
. Let's see an example with a simple schema:
Example (an Age
schema)
import { Schema } from "@effect/schema"
interface Age extends Schema.Schema<number> {}
const Age: Age = Schema.Number.pipe(Schema.between(0, 100))
type AgeType = Schema.Schema.Type<typeof Age>
type AgeEncoded = Schema.Schema.Encoded<typeof Age>
The benefit is that when we hover over the Age
schema, we see Age
instead of Schema<number, number, never>
. This is a small improvement if we only think about the Age
schema, but as we'll see shortly, these improvements in schema visualization add up, resulting in a significant improvement in the readability of our schemas.
Many of the built-in schemas exported from @effect/schema
have been equipped with API interfaces, for example number
or never
.
import { Schema } from "@effect/schema"
Schema.Number
Schema.Never
[!NOTE]
Notice that we had to add a $
suffix to the API interface name because we couldn't simply use "Number" since it's a reserved name for the TypeScript Number
type.
Now let's see an example with a combinator that, given an input schema for a certain type A
, returns the schema of the pair readonly [A, A]
:
Example (a pair
combinator)
import { Schema } from "@effect/schema"
export interface pair<S extends Schema.Schema.Any>
extends Schema.Schema<
readonly [Schema.Schema.Type<S>, Schema.Schema.Type<S>],
readonly [Schema.Schema.Encoded<S>, Schema.Schema.Encoded<S>],
Schema.Schema.Context<S>
> {}
export const pair = <S extends Schema.Schema.Any>(schema: S): pair<S> =>
Schema.Tuple(Schema.asSchema(schema), Schema.asSchema(schema))
[!NOTE]
The Schema.Schema.Any
helper represents any schema, except for never
. For more information on the asSchema
helper, refer to the following section "Understanding Opaque Names".
If we try to use our pair
combinator, we see that readability is also improved in this case:
const Coords = pair(Schema.Number)
In hover, we simply see pair<typeof Schema.Number>
instead of the verbose:
const Coords = Schema.Tuple(Schema.Number, Schema.Number)
The new name is not only shorter and more readable but also carries along the origin of the schema, which is a call to the pair
combinator.
Understanding Opaque Names
Opaque names generated in this way are very convenient, but sometimes there's a need to see what the underlying types are, perhaps for debugging purposes while you declare your schemas. At any time, you can use the asSchema
function, which returns an Schema<A, I, R>
compatible with your opaque definition:
const Coords = pair(Schema.Number)
const NonOpaqueCoords = Schema.asSchema(Coords)
[!NOTE]
The call to asSchema
is negligible in terms of overhead since it's nothing more than a glorified identity function.
Many of the built-in combinators exported from @effect/schema
have been equipped with API interfaces, for example struct
:
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
In hover, we simply see:
const Person: Schema.Struct<{
name: typeof Schema.String
age: typeof Schema.Number
}>
instead of the verbose:
const Person: Schema.Schema<
{
readonly name: string
readonly age: number
},
{
readonly name: string
readonly age: number
},
never
>
Exposing Arguments
The benefits of API interfaces don't end with better readability; in fact, the driving force behind the introduction of API interfaces arises more from the need to expose some important information about the schemas that users generate. Let's see some examples related to literals and structs:
Example (exposed literals)
Now when we define literals, we can retrieve them using the literals
field exposed by the generated schema:
import { Schema } from "@effect/schema"
const myliterals = Schema.Literal("A", "B")
myliterals.literals
console.log(myliterals.literals)
Example (exposed fields)
Similarly to what we've seen for literals, when we define a struct, we can retrieve its fields
:
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
Person.fields
console.log(Person.fields)
Being able to retrieve the fields
is particularly advantageous when you want to extend a struct with new fields; now you can do it simply using the spread operator:
import * as S from "@effect/schema/Schema"
import { Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const PersonWithId = Schema.Struct({
...Person.fields,
id: Schema.Number
})
The list of APIs equipped with API interfaces is extensive; here we provide only the main ones just to give you an idea of the new development possibilities that have opened up:
import { Schema } from "@effect/schema"
Schema.Array(Schema.String).value
Schema.Record({ key: Schema.String, value: Schema.Number }).key
Schema.Record({ key: Schema.String, value: Schema.Number }).value
Schema.Union(Schema.String, Schema.Number).members
Schema.Tuple(Schema.String, Schema.Number).elements
Troubleshooting When Working With Generic Schemas
Sometimes, while working with functions that handle generic schemas, you may encounter the issue where TypeScript fails to fully resolve the schema type, making it unusable within the function body. Let's see an example:
import { Schema } from "@effect/schema"
const MyStruct = <X extends Schema.Schema.All>(x: X) => Schema.Struct({ x })
type MyStructReturnType<X extends Schema.Schema.All> = Schema.Schema.Type<
ReturnType<typeof MyStruct<X>>
>
function test<X extends Schema.Schema.All>(obj: MyStructReturnType<X>) {
obj.x
}
In the function body, obj
has type
Simplify<Schema.Struct.Type<{ x: X }>>
so it's not possible to access the x
field.
To solve the problem, you need to force TypeScript to resolve the type of obj
, and you can do this with the type-level helper Schema.Schema.AsSchema
, which is the type-level counterpart of the function Schema.asSchema
:
function test<X extends Schema.Schema.All>(
obj: MyStructReturnType<Schema.Schema.AsSchema<X>>
) {
obj.x
}
Now the type of obj
is resolved to
{
readonly x: Schema.Schema.Type<X>;
}
and therefore, we can access its x
field.
Effect Data Types
Interop With Data
The effect/Data
module in the Effect ecosystem serves as a utility module that simplifies the process of comparing values for equality without the need for explicit implementations of the Equal
and Hash
interfaces. It provides convenient APIs that automatically generate default implementations for equality checks, making it easier for developers to perform equality comparisons in their applications.
import { Data, Equal } from "effect"
const person1 = Data.struct({ name: "Alice", age: 30 })
const person2 = Data.struct({ name: "Alice", age: 30 })
console.log(Equal.equals(person1, person2))
You can use the Schema.Data(schema)
combinator to build a schema from an existing schema that can decode a value A
to a value with Equal
and Hash
traits added:
import { Schema } from "@effect/schema"
import { Equal } from "effect"
const schema = Schema.Data(
Schema.Struct({
name: Schema.String,
age: Schema.Number
})
)
const decode = Schema.decode(schema)
const person1 = decode({ name: "Alice", age: 30 })
const person2 = decode({ name: "Alice", age: 30 })
console.log(Equal.equals(person1, person2))
Config
The Config
API in the @effect/schema
library is specifically designed to enhance configuration validation in software applications. This feature empowers developers to seamlessly integrate structured schema validation with configuration settings, ensuring that the configuration data is consistent with predefined schemas and providing detailed feedback when discrepancies are found.
The Config
function is defined as follows:
Config: <A>(name: string, schema: Schema<A, string>) => Config<A>
This function requires two parameters:
- name: The identifier for the configuration setting.
- schema: A schema object that describes the expected data type and structure.
The function returns a Config
object that is directly integrated with your application's configuration management system.
The Config
function operates through the following steps:
- Fetching Configuration: The configuration value is retrieved based on its name.
- Validation: The value is then validated against the schema. If the value does not conform to the schema, the function formats and returns detailed validation errors.
- Error Formatting: Errors are formatted using
TreeFormatter.formatErrorSync
to provide clear, actionable error messages.
Example
Below is a practical example illustrating how to use the Config
API:
import { Schema } from "@effect/schema"
import { Effect } from "effect"
const myconfig = Schema.Config("Foo", Schema.String.pipe(Schema.minLength(4)))
const program = Effect.gen(function* () {
const foo = yield* myconfig
console.log(`ok: ${foo}`)
})
Effect.runSync(program)
To test the configuration, execute the following commands:
Option
Cheatsheet
Combinator | From | To |
---|
Option | Schema<A, I, R> | Schema<Option<A>, OptionFrom<I>, R> |
OptionFromSelf | Schema<A, I, R> | Schema<Option<A>, Option<I>, R> |
OptionFromUndefinedOr | Schema<A, I, R> | Schema<Option<A>, I | undefined, R> |
OptionFromNullOr | Schema<A, I, R> | Schema<Option<A>, I | null, R> |
OptionFromNullishOr | Schema<A, I, R> , null | undefined | Schema<Option<A>, I | null | undefined, R> |
where
type OptionFrom<I> =
| {
readonly _tag: "None"
}
| {
readonly _tag: "Some"
readonly value: I
}
Option
- Decoding
{ _tag: "None" }
is converted to Option.none()
.{ _tag: "Some", value: i }
is converted to Option.some(a)
, where i
is decoded into a
using the inner schema.
- Encoding
Option.none()
is converted to { _tag: "None" }
.Option.some(a)
is converted to { _tag: "Some", value: i }
, where a
is encoded into i
using the inner schema.
import { Schema } from "@effect/schema"
import { Option } from "effect"
const schema = Schema.Option(Schema.NumberFromString)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode({ _tag: "None" }))
console.log(decode({ _tag: "Some", value: "1" }))
console.log(encode(Option.none()))
console.log(encode(Option.some(1)))
OptionFromSelf
- Decoding
Option.none()
remains as Option.none()
.Option.some(i)
is converted to Option.some(a)
, where i
is decoded into a
using the inner schema.
- Encoding
Option.none()
remains as Option.none()
.Option.some(a)
is converted to Option.some(i)
, where a
is encoded into i
using the inner schema.
import { Schema } from "@effect/schema"
import { Option } from "effect"
const schema = Schema.OptionFromSelf(Schema.NumberFromString)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode(Option.none()))
console.log(decode(Option.some("1")))
console.log(encode(Option.none()))
console.log(encode(Option.some(1)))
OptionFromUndefinedOr
- Decoding
undefined
is converted to Option.none()
.i
is converted to Option.some(a)
, where i
is decoded into a
using the inner schema.
- Encoding
Option.none()
is converted to undefined
.Option.some(a)
is converted to i
, where a
is encoded into i
using the inner schema.
import { Schema } from "@effect/schema"
import { Option } from "effect"
const schema = Schema.OptionFromUndefinedOr(Schema.NumberFromString)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode(undefined))
console.log(decode("1"))
console.log(encode(Option.none()))
console.log(encode(Option.some(1)))
OptionFromNullOr
- Decoding
null
is converted to Option.none()
.i
is converted to Option.some(a)
, where i
is decoded into a
using the inner schema.
- Encoding
Option.none()
is converted to null
.Option.some(a)
is converted to i
, where a
is encoded into i
using the inner schema.
import { Schema } from "@effect/schema"
import { Option } from "effect"
const schema = Schema.OptionFromNullOr(Schema.NumberFromString)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode(null))
console.log(decode("1"))
console.log(encode(Option.none()))
console.log(encode(Option.some(1)))
OptionFromNullishOr
-
Decoding
null
is converted to Option.none()
.undefined
is converted to Option.none()
.i
is converted to Option.some(a)
, where i
is decoded into a
using the inner schema.
-
Encoding
Option.none()
is converted to a specified value (undefined
or null
based on user choice).Option.some(a)
is converted to i
, where a
is encoded into i
using the inner schema.
import { Schema } from "@effect/schema"
import { Option } from "effect"
const schema = Schema.OptionFromNullishOr(Schema.NumberFromString, undefined)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode(null))
console.log(decode(undefined))
console.log(decode("1"))
console.log(encode(Option.none()))
console.log(encode(Option.some(1)))
OptionFromNonEmptyTrimmedString
-
Decoding
s
is converted to Option.some(s)
, if s.trim().length > 0
.Option.none()
otherwise.
-
Encoding
Option.none()
is converted to ""
.Option.some(s)
is converted to s
.
import { Schema } from "@effect/schema"
console.log(Schema.decodeSync(Schema.OptionFromNonEmptyTrimmedString)(""))
console.log(Schema.decodeSync(Schema.OptionFromNonEmptyTrimmedString)(" a "))
console.log(Schema.decodeSync(Schema.OptionFromNonEmptyTrimmedString)("a"))
Either
Either
- decoding
{ _tag: "Left", left: li }
-> Either.left(la)
{ _tag: "Right", right: ri }
-> Either.right(ra)
- encoding
Either.left(la)
-> { _tag: "Left", left: li }
Either.right(ra)
-> { _tag: "Right", right: ri }
import { Schema } from "@effect/schema"
import { Either } from "effect"
const schema = Schema.Either({
left: Schema.Trim,
right: Schema.NumberFromString
})
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode({ _tag: "Left", left: " a " }))
console.log(decode({ _tag: "Right", right: "1" }))
console.log(encode(Either.left("a")))
console.log(encode(Either.right(1)))
EitherFromSelf
- decoding
Either.left(li)
-> Either.left(la)
Either.right(ri)
-> Either.right(ra)
- encoding
Either.left(la)
-> Either.left(li)
Either.right(ra)
-> Either.right(ri)
import { Schema } from "@effect/schema"
import { Either } from "effect"
const schema = Schema.EitherFromSelf({
left: Schema.Trim,
right: Schema.NumberFromString
})
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode(Either.left(" a ")))
console.log(decode(Either.right("1")))
console.log(encode(Either.left("a")))
console.log(encode(Either.right(1)))
EitherFromUnion
- decoding
li
-> Either.left(la)
ri
-> Either.right(ra)
- encoding
Either.left(la)
-> li
Either.right(ra)
-> ri
import { Schema } from "@effect/schema"
import { Either } from "effect"
const schema = Schema.EitherFromUnion({
left: Schema.Boolean,
right: Schema.NumberFromString
})
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode(true))
console.log(decode("1"))
console.log(encode(Either.left(true)))
console.log(encode(Either.right(1)))
ReadonlySet
ReadonlySet
- decoding
ReadonlyArray<I>
-> ReadonlySet<A>
- encoding
ReadonlySet<A>
-> ReadonlyArray<I>
import { Schema } from "@effect/schema"
const schema = Schema.ReadonlySet(Schema.NumberFromString)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode(["1", "2", "3"]))
console.log(encode(new Set([1, 2, 3])))
ReadonlySetFromSelf
- decoding
ReadonlySet<I>
-> ReadonlySet<A>
- encoding
ReadonlySet<A>
-> ReadonlySet<I>
import { Schema } from "@effect/schema"
const schema = Schema.ReadonlySetFromSelf(Schema.NumberFromString)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode(new Set(["1", "2", "3"])))
console.log(encode(new Set([1, 2, 3])))
ReadonlyMap
ReadonlyMap
- decoding
ReadonlyArray<readonly [KI, VI]>
-> ReadonlyMap<KA, VA>
- encoding
ReadonlyMap<KA, VA>
-> ReadonlyArray<readonly [KI, VI]>
import { Schema } from "@effect/schema"
const schema = Schema.ReadonlyMap({
key: Schema.String,
value: Schema.NumberFromString
})
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(
decode([
["a", "2"],
["b", "2"],
["c", "3"]
])
)
console.log(
encode(
new Map([
["a", 1],
["b", 2],
["c", 3]
])
)
)
ReadonlyMapFromSelf
- decoding
ReadonlyMap<KI, VI>
-> ReadonlyMap<KA, VA>
- encoding
ReadonlyMap<KA, VA>
-> ReadonlyMap<KI, VI>
import { Schema } from "@effect/schema"
const schema = Schema.ReadonlyMapFromSelf({
key: Schema.String,
value: Schema.NumberFromString
})
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(
decode(
new Map([
["a", "2"],
["b", "2"],
["c", "3"]
])
)
)
console.log(
encode(
new Map([
["a", 1],
["b", 2],
["c", 3]
])
)
)
ReadonlyMapFromRecord
- decoding
{ readonly [x: string]: VI }
-> ReadonlyMap<KA, VA>
- encoding
ReadonlyMap<KA, VA>
-> { readonly [x: string]: VI }
import { Schema } from "@effect/schema"
const schema = Schema.ReadonlyMapFromRecord({
key: Schema.BigInt,
value: Schema.NumberFromString
})
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(
decode({
"1": "4",
"2": "5",
"3": "6"
})
)
console.log(
encode(
new Map([
[1n, 4],
[2n, 5],
[3n, 6]
])
)
)
HashSet
HashSet
- decoding
ReadonlyArray<I>
-> HashSet<A>
- encoding
HashSet<A>
-> ReadonlyArray<I>
import { Schema } from "@effect/schema"
import { HashSet } from "effect"
const schema = Schema.HashSet(Schema.NumberFromString)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode(["1", "2", "3"]))
console.log(encode(HashSet.fromIterable([1, 2, 3])))
HashSetFromSelf
import { Schema } from "@effect/schema"
import { HashSet } from "effect"
const schema = Schema.HashSetFromSelf(Schema.NumberFromString)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode(HashSet.fromIterable(["1", "2", "3"])))
console.log(encode(HashSet.fromIterable([1, 2, 3])))
HashMap
HashMap
- decoding
ReadonlyArray<readonly [KI, VI]>
-> HashMap<KA, VA>
- encoding
HashMap<KA, VA>
-> ReadonlyArray<readonly [KI, VI]>
import { Schema } from "@effect/schema"
import { HashMap } from "effect"
const schema = Schema.HashMap({
key: Schema.String,
value: Schema.NumberFromString
})
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(
decode([
["a", "2"],
["b", "2"],
["c", "3"]
])
)
console.log(
encode(
HashMap.fromIterable([
["a", 1],
["b", 2],
["c", 3]
])
)
)
HashMapFromSelf
- decoding
HashMap<KI, VI>
-> HashMap<KA, VA>
- encoding
HashMap<KA, VA>
-> HashMap<KI, VI>
import { Schema } from "@effect/schema"
import { HashMap } from "effect"
const schema = Schema.HashMapFromSelf({
key: Schema.String,
value: Schema.NumberFromString
})
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(
decode(
HashMap.fromIterable([
["a", "2"],
["b", "2"],
["c", "3"]
])
)
)
console.log(
encode(
HashMap.fromIterable([
["a", 1],
["b", 2],
["c", 3]
])
)
)
SortedSet
SortedSet
- decoding
ReadonlyArray<I>
-> SortedSet<A>
- encoding
SortedSet<A>
-> ReadonlyArray<I>
import { Schema } from "@effect/schema"
import { Number, SortedSet } from "effect"
const schema = Schema.SortedSet(Schema.NumberFromString, Number.Order)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode(["1", "2", "3"]))
console.log(encode(SortedSet.fromIterable(Number.Order)([1, 2, 3])))
SortedSetFromSelf
- decoding
SortedSet<I>
-> SortedSet<A>
- encoding
SortedSet<A>
-> SortedSet<I>
import { Schema } from "@effect/schema"
import { Number, SortedSet, String } from "effect"
const schema = Schema.SortedSetFromSelf(
Schema.NumberFromString,
Number.Order,
String.Order
)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
console.log(decode(SortedSet.fromIterable(String.Order)(["1", "2", "3"])))
console.log(encode(SortedSet.fromIterable(Number.Order)([1, 2, 3])))
Duration
Duration
Converts an hrtime(i.e. [seconds: number, nanos: number]
) into a Duration
.
import { Schema } from "@effect/schema"
const schema = Schema.Duration
const decode = Schema.decodeUnknownSync(schema)
console.log(decode([0, 0]))
console.log(decode([5000, 0]))
DurationFromSelf
The DurationFromSelf
schema is designed to validate that a given value conforms to the Duration
type from the effect
library.
import { Schema } from "@effect/schema"
import { Duration } from "effect"
const schema = Schema.DurationFromSelf
const decode = Schema.decodeUnknownSync(schema)
console.log(decode(Duration.seconds(2)))
console.log(decode(null))
DurationFromMillis
Converts a number
into a Duration
where the number represents the number of milliseconds.
import { Schema } from "@effect/schema"
const schema = Schema.DurationFromMillis
const decode = Schema.decodeUnknownSync(schema)
console.log(decode(0))
console.log(decode(5000))
DurationFromNanos
Converts a BigInt
into a Duration
where the number represents the number of nanoseconds.
import { Schema } from "@effect/schema"
const schema = Schema.DurationFromNanos
const decode = Schema.decodeUnknownSync(schema)
console.log(decode(0n))
console.log(decode(5000000000n))
clampDuration
Clamps a Duration
between a minimum and a maximum value.
import { Schema } from "@effect/schema"
import { Duration } from "effect"
const schema = Schema.DurationFromSelf.pipe(
Schema.clampDuration("5 seconds", "10 seconds")
)
const decode = Schema.decodeUnknownSync(schema)
console.log(decode(Duration.decode("2 seconds")))
console.log(decode(Duration.decode("6 seconds")))
console.log(decode(Duration.decode("11 seconds")))
Redacted
Redacted
The Redacted
schema in @effect/schema
is specifically designed to handle sensitive information by converting a string
into a Redacted
object. This transformation ensures that the sensitive data is not exposed in the application's output.
import { Schema } from "@effect/schema"
const schema = Schema.Redacted(Schema.String)
const decode = Schema.decodeUnknownSync(schema)
console.log(decode("keep it secret, keep it safe"))
Note on Logging
It's important to note that when successfully decoding a Redacted
, the output is intentionally obscured ({}
) to prevent the actual secret from being revealed in logs or console outputs.
Warning on Schema Composition
When composing the Redacted
schema with other schemas, care must be taken as decoding or encoding errors could potentially expose sensitive information.
Practical Example Showing Potential Data Exposure
import { Schema } from "@effect/schema"
import { Redacted } from "effect"
const schema = Schema.Trimmed.pipe(
Schema.compose(Schema.Redacted(Schema.String))
)
console.log(Schema.decodeUnknownEither(schema)(" 123"))
console.log(Schema.encodeEither(schema)(Redacted.make(" 123")))
In the example above, if the input string does not meet the criteria (e.g., contains spaces), the error message generated might inadvertently expose sensitive information included in the input.
Mitigating Exposure Risks
To reduce the risk of sensitive information leakage in error messages, you can customize the error messages to obscure sensitive details:
import { Schema } from "@effect/schema"
import { Redacted } from "effect"
const schema = Schema.Trimmed.annotations({
message: () => "Expected Trimmed, actual <redacted>"
}).pipe(Schema.compose(Schema.Redacted(Schema.String)))
console.log(Schema.decodeUnknownEither(schema)(" 123"))
console.log(Schema.encodeEither(schema)(Redacted.make(" 123")))
RedactedFromSelf
The RedactedFromSelf
schema is designed to validate that a given value conforms to the Redacted
type from the effect
library.
import { Schema } from "@effect/schema"
import { Redacted } from "effect"
const schema = Schema.RedactedFromSelf(Schema.String)
const decode = Schema.decodeUnknownSync(schema)
console.log(decode(Redacted.make("mysecret")))
console.log(decode(null))
Note on Logging
It's important to note that when successfully decoding a Redacted
, the output is intentionally obscured ({}
) to prevent the actual secret from being revealed in logs or console outputs.
Serializable
The Serializable
module enables objects to have self-contained schema(s) for serialization. This functionality is particularly beneficial in scenarios where objects need to be consistently serialized and deserialized across various runtime environments or sent over network communications.
Serializable trait
The Serializable
trait equips objects with the capability to define their serialization logic explicitly.
interface Serializable<A, I, R> {
readonly [symbol]: Schema.Schema<A, I, R>
}
Example: Implementing the Serializable Trait
import { Schema, Serializable } from "@effect/schema"
import { Effect } from "effect"
class Person {
constructor(
readonly id: number,
readonly name: string,
readonly createdAt: Date
) {}
static FromEncoded = Schema.transform(
Schema.Struct({
id: Schema.Number,
name: Schema.String,
createdAt: Schema.Date
}),
Schema.instanceOf(Person),
{
decode: ({ createdAt, id, name }) => new Person(id, name, createdAt),
encode: ({ createdAt, id, name }) => ({ id, name, createdAt })
}
)
get [Serializable.symbol]() {
return Person.FromEncoded
}
}
const person = new Person(1, "John", new Date(0))
const serialized = Effect.runSync(Serializable.serialize(person))
console.log(serialized)
const deserialized = Schema.decodeUnknownSync(Person.FromEncoded)(serialized)
console.log(deserialized)
const deserializedUsingAnInstance = Effect.runSync(
Serializable.deserialize(person, serialized)
)
console.log(deserializedUsingAnInstance)
Streamlining Code with Schema.Class
While the above example provides a comprehensive view of serialization processes, using the Schema.Class
API can significantly reduce boilerplate and simplify class modeling.
import { Schema, Serializable } from "@effect/schema"
import { Effect } from "effect"
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.String,
createdAt: Schema.Date
}) {
get [Serializable.symbol]() {
return Person
}
}
const person = new Person({ id: 1, name: "John", createdAt: new Date(0) })
const serialized = Effect.runSync(Serializable.serialize(person))
console.log(serialized)
const deserialized = Schema.decodeUnknownSync(Person)(serialized)
console.log(deserialized)
const deserializedUsingAnInstance = Effect.runSync(
Serializable.deserialize(person, serialized)
)
console.log(deserializedUsingAnInstance)
WithResult trait
The WithResult
trait is designed to encapsulate the outcome of an operation, distinguishing between success and failure cases. Each case is associated with a schema that defines the structure and types of the success or failure data.
interface WithResult<
Success,
SuccessEncoded,
Failure,
FailureEncoded,
ResultR
> {
readonly [symbolResult]: {
readonly success: Schema.Schema<Success, SuccessEncoded, ResultR>
readonly failure: Schema.Schema<Failure, FailureEncoded, ResultR>
}
}
SerializableWithResult trait
The SerializableWithResult
trait is specifically designed to model remote procedures that require serialization of their input and output, managing both successful and failed outcomes.
This trait combines functionality from both the Serializable
and WithResult
traits to handle data serialization and the bifurcation of operation results into success or failure categories.
Definition
interface SerializableWithResult<
A,
I,
R,
Success,
SuccessEncoded,
Failure,
FailureEncoded,
ResultR
> extends Serializable<A, I, R>,
WithResult<Success, SuccessEncoded, Failure, FailureEncoded, ResultR> {}
Components
- Payload (
A, I, R
): The payload is described using the Serializable<A, I, R>
trait, which includes the type of the payload (A
), its serialized form (I
), and any relevant runtime context (R
). - Success Case (
Success, SuccessEncoded, ResultR
): Defined by Schema<Success, SuccessEncoded, ResultR>
, this outlines the structure and type of the data upon a successful operation, along with its serialized form. - Failure Case (
Failure, FailureEncoded, ResultR
): This is analogous to the Success Case but caters to scenarios where the operation fails. It is described by Schema<Failure, FailureEncoded, ResultR>
.
Workflow
- Initialization: Begin with data of type
A
. - Serialization: Convert this data into its serialized format
I
. - Transmission: Send this serialized data over the network.
- Reception and Deserialization: Upon receiving, convert the data back from type
I
to A
. - Processing: The deserialized data is then processed to determine the outcome as either success (
Success
) or failure (Failure
). - Result Serialization: Depending on the outcome, serialize the result into
Exit<SuccessEncoded, FailureEncoded>
. - Response Transmission: Send the serialized outcome back over the network.
- Final Deserialization: Deserialize the received outcome back into
Exit<Success, Failure>
for final use.
sequenceDiagram
Sender->>SenderBound: encodes A to I
SenderBound-->>ReceiverBound: send I
ReceiverBound->>Receiver: decodes I to A
Receiver->>ReceiverBound: encodes Exit<Success, Failure><br/>to Exit<SuccessEncoded, FailureEncoded>
ReceiverBound-->>SenderBound: send back<br/>Exit<SuccessEncoded, FailureEncoded>
SenderBound->>Sender: decodes Exit<SuccessEncoded, FailureEncoded><br/>to Exit<Success, Failure>
Example
import type { ParseResult } from "@effect/schema"
import { Schema, Serializable } from "@effect/schema"
import { Effect, Exit } from "effect"
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.String,
createdAt: Schema.Date
}) {
get [Serializable.symbol]() {
return Person
}
}
class GetPersonById {
constructor(readonly id: number) {}
static FromEncoded = Schema.transform(
Schema.Number,
Schema.instanceOf(GetPersonById),
{
decode: (id) => new GetPersonById(id),
encode: ({ id }) => id
}
)
get [Serializable.symbol]() {
return GetPersonById.FromEncoded
}
get [Serializable.symbolResult]() {
return {
success: Person,
failure: Schema.String
}
}
}
function handleGetPersonById(
serializedReq: typeof GetPersonById.FromEncoded.Encoded
) {
return Effect.gen(function* () {
const req = yield* Schema.decodeUnknown(GetPersonById.FromEncoded)(
serializedReq
)
return yield* Serializable.serializeExit(
req,
req.id === 0
? Exit.fail("User not found")
: Exit.succeed(
new Person({ id: req.id, name: "John", createdAt: new Date() })
)
)
})
}
const roundtrip = (
req: GetPersonById
): Effect.Effect<Exit.Exit<Person, string>, ParseResult.ParseError> =>
Effect.gen(function* () {
const serializedReq = yield* Serializable.serialize(req)
const exit = yield* handleGetPersonById(serializedReq)
return yield* Serializable.deserializeExit(req, exit)
})
console.log(Effect.runSync(roundtrip(new GetPersonById(1))))
console.log(Effect.runSync(roundtrip(new GetPersonById(0))))
Streamlining Code with Schema.TaggedRequest
While the previous example effectively demonstrates the mechanisms involved, it does require a significant amount of boilerplate code. To streamline development, the Schema.TaggedRequest
API is specifically designed to reduce complexity and increase readability.
import type { ParseResult } from "@effect/schema"
import { Schema, Serializable } from "@effect/schema"
import { Effect, Exit } from "effect"
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.String,
createdAt: Schema.Date
}) {}
class GetPersonById extends Schema.TaggedRequest<GetPersonById>()(
"GetPersonById",
{
payload: { id: Schema.Number },
success: Person,
failure: Schema.String
}
) {}
function handleGetPersonById(serializedReq: typeof GetPersonById.Encoded) {
return Effect.gen(function* () {
const req = yield* Schema.decodeUnknown(GetPersonById)(serializedReq)
return yield* Serializable.serializeExit(
req,
req.id === 0
? Exit.fail("User not found")
: Exit.succeed(
new Person({ id: req.id, name: "John", createdAt: new Date() })
)
)
})
}
const roundtrip = (
req: GetPersonById
): Effect.Effect<Exit.Exit<Person, string>, ParseResult.ParseError> =>
Effect.gen(function* () {
const serializedReq = yield* Serializable.serialize(req)
const exit = yield* handleGetPersonById(serializedReq)
return yield* Serializable.deserializeExit(req, exit)
})
console.log(Effect.runSync(roundtrip(new GetPersonById({ id: 1 }))))
console.log(Effect.runSync(roundtrip(new GetPersonById({ id: 0 }))))
Communication and Serialization with Schema and Serializable Traits
This section outlines a streamlined client-server interaction using the Serializable
and WithResult
traits from the @effect/schema
library to manage serialization and processing of data objects across network communications.
Client-Side Operations:
- Initialization: Start with an object of type
A
, which implements Serializable.SerializableWithResult
. - Serialization: Serialize the object
A
using Serializable.serialize
, which employs the schema retrieved from the Serializable
interface tied to A
. - Transmission: Send the serialized data of type
I
to the server and wait for a response.
Server-Side Operations:
- Reception: Receive the serialized data
I
. - Deserialization: Convert the serialized data
I
back into an object of type A
using a predefined union schema Schema<A | B | ..., I | IB | ...>
. - Processing: Handle the message of type
A
to derive an outcome as Exit<Success, Failure>
. - Result Serialization: Serialize the result
Exit<Success, Failure>
to Exit<SuccessEncoded, FailureEncoded>
utilizing the schema obtained from A
's WithResult
interface. - Response: Send the serialized response
Exit<SuccessEncoded, FailureEncoded>
back to the client.
Client-Side Response Handling:
- Reception: Receive the response
Exit<SuccessEncoded, FailureEncoded>
. - Final Deserialization: Convert
Exit<SuccessEncoded, FailureEncoded>
back to Exit<Success, Failure>
using the original object A
and the schema from the WithResult
interface.
Generating Arbitraries
The make
function within the @effect/schema/Arbitrary
module allows for the creation of random values that align with a specific Schema<A, I, R>
. This utility returns an Arbitrary<A>
from the fast-check library, which is particularly useful for generating random test data that adheres to the defined schema constraints.
import { Arbitrary, FastCheck, Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.NonEmptyString,
age: Schema.NumberFromString.pipe(Schema.int(), Schema.between(0, 200))
})
const PersonArbitraryType = Arbitrary.make(Person)
console.log(FastCheck.sample(PersonArbitraryType, 2))
const PersonArbitraryEncoded = Arbitrary.make(Schema.encodedSchema(Person))
console.log(FastCheck.sample(PersonArbitraryEncoded, 2))
Understanding Schema Transformations and Arbitrary Generation
The generation of arbitrary data requires a clear understanding of how transformations and filters are applied within a schema:
- Transformations and Filters: Only the filters applied after the last transformation in the transformation chain are considered during arbitrary generation.
import { Arbitrary, FastCheck, Schema } from "@effect/schema"
const schema1 = Schema.compose(Schema.NonEmptyString, Schema.Trim).pipe(
Schema.maxLength(500)
)
console.log(FastCheck.sample(Arbitrary.make(schema1), 10))
const schema2 = Schema.Trim.pipe(Schema.nonEmptyString(), Schema.maxLength(500))
console.log(FastCheck.sample(Arbitrary.make(schema2), 10))
Explanation:
- Schema 1: Considers the
Schema.maxLength(500)
because it follows the Schema.Trim
transformation but disregards Schema.NonEmptyString
as it comes before any transformations. - Schema 2: Properly adheres to all applied filters by ensuring they follow transformations, thus avoiding the generation of undesired data.
Best Practices
Organize transformations and filters to ensure clarity and effectiveness in data generation. Follow the pattern: (I filters) -> (transformations) -> (A filters)
where "I" and "A" stand for the initial and transformed types in the schema.
"I" and "A" represent the initial and final types in the schema, ensuring that each stage of data processing is clearly defined.
Instead of indiscriminately combining transformations and filters:
import { Schema } from "@effect/schema"
const schema = Schema.compose(
Schema.Lowercase,
Schema.Trim
)
Prefer separating transformation steps from filter applications:
import { Schema } from "@effect/schema"
const schema = Schema.transform(
Schema.String,
Schema.String.pipe(Schema.trimmed(), Schema.lowercased()),
{
strict: true,
decode: (s) => s.trim().toLowerCase(),
encode: (s) => s
}
)
Customizations
You can customize the output by using the arbitrary
annotation:
import { Arbitrary, FastCheck, Schema } from "@effect/schema"
const schema = Schema.Number.annotations({
arbitrary: () => (fc) => fc.nat()
})
const arb = Arbitrary.make(schema)
console.log(FastCheck.sample(arb, 2))
[!WARNING]
Customizing a schema can disrupt previously applied filters. Filters set after the customization will remain effective, while those applied before will be disregarded.
Example
import { Arbitrary, FastCheck, Schema } from "@effect/schema"
const problematic = Schema.Number.pipe(Schema.positive()).annotations({
arbitrary: () => (fc) => fc.integer()
})
console.log(FastCheck.sample(Arbitrary.make(problematic), 2))
const improved = Schema.Number.annotations({
arbitrary: () => (fc) => fc.integer()
}).pipe(Schema.positive())
console.log(FastCheck.sample(Arbitrary.make(improved), 2))
Generating JSON Schemas
The make
function in the @effect/schema/JSONSchema
module allows you to generate a JSON Schema from a predefined schema.
Here's an example where we define a schema for a "Person" with properties "name" (a string) and "age" (a number). Using the JSONSchema.make
function, we generate the corresponding JSON Schema.
import { JSONSchema, Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const jsonSchema = JSONSchema.make(Person)
console.log(JSON.stringify(jsonSchema, null, 2))
The JSONSchema.make
function aims to produce an optimal JSON Schema representing the input part of the decoding phase. It does this by traversing the schema from the most nested component, incorporating each refinement, and stops at the first transformation encountered.
Consider a modification to the schema of the age
field:
import { JSONSchema, Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number.pipe(
Schema.int(),
Schema.clamp(1, 10)
)
})
const jsonSchema = JSONSchema.make(Person)
console.log(JSON.stringify(jsonSchema, null, 2))
The new JSON Schema for the age
field shows it as type "integer"
, keeping the refinement of being an integer and excluding the transformation that clamps the value between 1
and 10
.
Identifier Annotations
You can augment your schemas with identifier
annotations to enhance their structure and maintainability. When you utilize these annotations, your schemas are included within a "$defs" object property at the root of the JSON Schema and referenced from there, enabling better organization and readability.
import { JSONSchema, Schema } from "@effect/schema"
const Name = Schema.String.annotations({ identifier: "Name" })
const Age = Schema.Number.annotations({ identifier: "Age" })
const Person = Schema.Struct({
name: Name,
age: Age
})
const jsonSchema = JSONSchema.make(Person)
console.log(JSON.stringify(jsonSchema, null, 2))
By structuring your JSON Schema with identifier annotations, each annotated schema is clearly defined in a separate section, making the entire schema easier to navigate and maintain. This approach is especially useful for complex schemas that require clear documentation of each component.
Standard JSON Schema Annotations
Standard JSON Schema annotations such as title
, description
, default
, and examples
are well supported in the @effect/schema
library. These annotations allow you to enrich your schemas with metadata that can enhance readability and provide additional information about the data structure.
import { JSONSchema, Schema } from "@effect/schema"
const schema = Schema.String.annotations({
description: "my custom description",
title: "my custom title",
default: "",
examples: ["a", "b"]
})
const jsonSchema = JSONSchema.make(schema)
console.log(JSON.stringify(jsonSchema, null, 2))
Adding annotations to struct properties
To enhance the clarity of your JSON schemas, it's advisable to add annotations directly to the property signatures rather than to the type itself. This method is more semantically appropriate as it links descriptive titles and other metadata specifically to the properties they describe, rather than to the generic type.
import { JSONSchema, Schema } from "@effect/schema"
const Person = Schema.Struct({
firstName: Schema.propertySignature(Schema.String).annotations({
title: "First name"
}),
lastName: Schema.propertySignature(Schema.String).annotations({
title: "Last Name"
})
})
const jsonSchema = JSONSchema.make(Person)
console.log(JSON.stringify(jsonSchema, null, 2))
Recursive and Mutually Recursive Schemas
Recursive and mutually recursive schemas are well-supported in the @effect/schema
library. However, it's mandatory to use identifier annotations for these types of schemas to ensure correct references and definitions within the generated JSON Schema.
import { JSONSchema, Schema } from "@effect/schema"
interface Category {
readonly name: string
readonly categories: ReadonlyArray<Category>
}
const Category = Schema.Struct({
name: Schema.String,
categories: Schema.Array(
Schema.suspend((): Schema.Schema<Category> => Category)
)
}).annotations({ identifier: "Category" })
const jsonSchema = JSONSchema.make(Category)
console.log(JSON.stringify(jsonSchema, null, 2))
In this example, the Category
schema refers to itself, making it necessary to use an identifier annotation to facilitate the reference.
Custom JSON Schema Annotations
When working with JSON Schema in the @effect/schema
library, certain data types, such as bigint
, lack a direct representation because JSON Schema does not natively support them. This absence typically leads to an error when the schema is generated:
import { JSONSchema, Schema } from "@effect/schema"
const schema = Schema.Struct({
a_bigint_field: Schema.BigIntFromSelf
})
const jsonSchema = JSONSchema.make(schema)
console.log(JSON.stringify(jsonSchema, null, 2))
To address this, you can enhance the schema with a custom annotation, defining how you intend to represent such types in JSON Schema:
import { JSONSchema, Schema } from "@effect/schema"
const schema = Schema.Struct({
a_bigint_field: Schema.BigIntFromSelf.annotations({
jsonSchema: { type: "some custom way to represent a bigint in JSON Schema" }
})
})
const jsonSchema = JSONSchema.make(schema)
console.log(JSON.stringify(jsonSchema, null, 2))
Custom JSON Schema Annotations for Refinements
When defining a refinement (e.g., through the filter
function), you can attach a JSON Schema annotation to your schema containing a JSON Schema "fragment" related to this particular refinement. This fragment will be used to generate the corresponding JSON Schema. Note that if the schema consists of more than one refinement, the corresponding annotations will be merged.
import { JSONSchema, Schema } from "@effect/schema"
const Positive = Schema.Number.pipe(
Schema.filter((n) => n > 0, {
jsonSchema: { minimum: 0 }
})
)
const schema = Positive.pipe(
Schema.filter((n) => n <= 10, {
jsonSchema: { maximum: 10 }
})
)
const jsonSchema = JSONSchema.make(schema)
console.log(JSON.stringify(jsonSchema, null, 2))
Note:
The jsonSchema
property is intentionally defined as a generic object. This allows it to describe non-standard extensions.
As a result, the responsibility of enforcing type constraints is left to you, the user.
If you prefer stricter type enforcement or need to support non-standard extensions, you can introduce a satisfies
constraint on the object literal. This constraint should be used in conjunction with the typing library of your choice.
In the following example, we've used the @types/json-schema
package to provide TypeScript definitions for JSON Schema. This approach not only ensures type correctness but also enables autocomplete suggestions in your IDE.
import { JSONSchema, Schema } from "@effect/schema"
import type { JSONSchema7 } from "json-schema"
const Positive = Schema.Number.pipe(
Schema.filter((n) => n > 0, {
jsonSchema: { minimum: 0 }
})
)
const schema = Positive.pipe(
Schema.filter((n) => n <= 10, {
jsonSchema: { maximum: 10 } satisfies JSONSchema7
})
)
const jsonSchema = JSONSchema.make(schema)
console.log(JSON.stringify(jsonSchema, null, 2))
For all other types of schema that are not refinements, the content of the annotation is used and overrides anything the system would have generated by default:
import { JSONSchema, Schema } from "@effect/schema"
const schema = Schema.Struct({ foo: Schema.String }).annotations({
jsonSchema: { type: "object" }
})
const jsonSchema = JSONSchema.make(schema)
console.log(JSON.stringify(jsonSchema, null, 2))
Understanding Schema.parseJson
in JSON Schema Generation
When utilizing Schema.parseJson
, JSON Schema generation follows a specialized approach. Instead of merely generating a JSON Schema for a string—which would be the default output representing the "from" side of the transformation defined by Schema.parseJson
—it specifically generates the JSON Schema for the actual schema provided as an argument.
Example of Generating JSON Schema with Schema.parseJson
import { JSONSchema, Schema } from "@effect/schema"
const schema = Schema.parseJson(
Schema.Struct({
a: Schema.parseJson(Schema.NumberFromString)
})
)
const jsonSchema = JSONSchema.make(schema)
console.log(JSON.stringify(jsonSchema, null, 2))
Generating Equivalences
The make
function, which is part of the @effect/schema/Equivalence
module, allows you to generate an Equivalence based on a schema definition:
import { Equivalence, Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const PersonEquivalence = Equivalence.make(Person)
const john = { name: "John", age: 23 }
const alice = { name: "Alice", age: 30 }
console.log(PersonEquivalence(john, { name: "John", age: 23 }))
console.log(PersonEquivalence(john, alice))
Customizations
You can customize the output using the equivalence
annotation:
import { Equivalence, Schema } from "@effect/schema"
const schema = Schema.String.annotations({
equivalence: () => (a, b) => a.at(0) === b.at(0)
})
console.log(Equivalence.make(schema)("aaa", "abb"))
Generating Pretty Printers
The make
function provided by the @effect/schema/Pretty
module represents a way of pretty-printing values that conform to a given Schema
.
You can use the make
function to create a human-readable string representation of a value that conforms to a Schema
. This can be useful for debugging or logging purposes, as it allows you to easily inspect the structure and data types of the value.
import { Pretty, Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const PersonPretty = Pretty.make(Person)
console.log(PersonPretty({ name: "Alice", age: 30 }))
Customizations
You can customize the output using the pretty
annotation:
import { Pretty, Schema } from "@effect/schema"
const schema = Schema.Number.annotations({
pretty: () => (n) => `my format: ${n}`
})
console.log(Pretty.make(schema)(1))
Technical overview
A schema is a description of a data structure that can be used to generate various artifacts from a single declaration.
From a technical point of view a schema is just a typed wrapper of an AST
value:
interface Schema<A, I, R> {
readonly ast: AST
}
The AST
type represents a tiny portion of the TypeScript AST, roughly speaking the part describing ADTs (algebraic data types),
i.e. products (like structs and tuples) and unions, plus a custom transformation node.
This means that you can define your own schema constructors / combinators as long as you are able to manipulate the AST
value accordingly, let's see an example.
Say we want to define a pair
schema constructor, which takes a Schema<A, I, R>
as input and returns a Schema<readonly [A, A], readonly [I, I], R>
as output.
First of all we need to define the signature of pair
import type { Schema } from "@effect/schema"
declare const pair: <A, I, R>(
schema: Schema.Schema<A, I, R>
) => Schema.Schema<readonly [A, A], readonly [I, I], R>
Then we can implement the body using the APIs exported by the @effect/schema/AST
module:
import { AST, Schema } from "@effect/schema"
const pair = <A, I, R>(
schema: Schema.Schema<A, I, R>
): Schema.Schema<readonly [A, A], readonly [I, I], R> => {
const element = new AST.Element(
schema.ast,
false
)
const tuple = new AST.TupleType(
[element, element],
[],
true
)
return Schema.make(tuple)
}
This example demonstrates the use of the low-level APIs of the AST
module, however, the same result can be achieved more easily and conveniently by using the high-level APIs provided by the Schema
module.
import { Schema } from "@effect/schema"
const pair = <A, I, R>(
schema: Schema.Schema<A, I, R>
): Schema.Schema<readonly [A, A], readonly [I, I], R> =>
Schema.Tuple(schema, schema)
FAQ
Is it Possible to Extend Functionality Beyond Built-in APIs?
If your needs aren't addressed by the existing built-in APIs, you have the option to craft your own API using the built-in APIs as a foundation. If these still don't suffice, you can delve into the lower-level APIs provided by the @effect/schema/AST
module.
To develop a robust custom API, you need to address two primary challenges:
- Type-level challenge: Can you define the TypeScript signature for your API?
- Runtime-level challenge: Can you implement your API at runtime using either the
Schema
or AST
module APIs?
Let's explore a practical example: "Is it possible to make all fields of a struct nullable?"
Defining the API Signature in TypeScript
First, let's determine if we can define the API's TypeScript signature:
import { Schema } from "@effect/schema"
const nullableFields = <Fields extends { readonly [x: string]: Schema.Schema.Any }>(
schema: Schema.Struct<Fields>
): Schema.Struct<{ [K in keyof Fields]: Schema.NullOr<Fields[K]> }>
const schema = nullableFields(Schema.Struct({
name: Schema.String,
age: Schema.Number
}))
You can preliminarily define the signature of nullableFields
using TypeScript's declare
keyword, allowing you to immediately test its validity (at the type-level, initially). The example above confirms that the API behaves as expected by inspecting a schema that utilizes this new API.
const schema: Schema.Struct<{
name: Schema.NullOr<typeof Schema.String>
age: Schema.NullOr<typeof Schema.Number>
}>
Implementing the API at Runtime
import { Schema } from "@effect/schema"
import { Record } from "effect"
const nullableFields = <
Fields extends { readonly [x: string]: Schema.Schema.Any }
>(
schema: Schema.Struct<Fields>
): Schema.Struct<{ [K in keyof Fields]: Schema.NullOr<Fields[K]> }> => {
return Schema.Struct(
Record.map(schema.fields, (schema) => Schema.NullOr(schema)) as any as {
[K in keyof Fields]: Schema.NullOr<Fields[K]>
}
)
}
const schema = nullableFields(
Schema.Struct({
name: Schema.String,
age: Schema.Number
})
)
console.log(Schema.decodeUnknownSync(schema)({ name: "a", age: null }))
Comparisons
Zod (v3)
Feature-wise, schema
can do practically everything that zod
can do.
The main differences are:
schema
transformations are bidirectional, so it not only decodes like zod
but also encodes.schema
is integrated with Effect
and inherits some benefits from it (such as dependency tracking in transformations).schema
is highly customizable through annotations, allowing users to attach meta-information.schema
uses a functional programming style with combinators and transformations (while zod
provides a chainable API).
Basic usage
Zod
import { z } from "zod"
const mySchema = z.string()
mySchema.parse("tuna")
mySchema.parse(12)
mySchema.safeParse("tuna")
mySchema.safeParse(12)
Schema
import { Schema as S } from "@effect/schema"
const mySchema = S.String
S.decodeUnknownSync(mySchema)("tuna")
S.decodeUnknownSync(mySchema)(12)
S.decodeUnknownEither(mySchema)("tuna")
S.decodeUnknownEither(mySchema)(12)
Creating an object schema
Zod
import { z } from "zod"
const User = z.object({
username: z.string()
})
User.parse({ username: "Ludwig" })
type User = z.infer<typeof User>
Schema
import { Schema as S } from "@effect/schema"
const User = S.Struct({
username: S.String
})
S.decodeUnknownSync(User)({ username: "Ludwig" })
type User = S.Schema.Type<typeof User>
Primitives
Zod
import { z } from "zod"
z.string()
z.number()
z.bigint()
z.boolean()
z.date()
z.symbol()
z.undefined()
z.null()
z.void()
z.any()
z.unknown()
z.never()
Schema
import { Schema as S } from "@effect/schema"
S.String
S.Number
S.BigInt
S.Boolean
S.Date
S.Symbol
S.Undefined
S.Null
S.Void
S.Any
S.Unknown
S.Never
Coercion for primitives
No equivalent.
Literals
Zod
const tuna = z.literal("tuna")
const twelve = z.literal(12)
const twobig = z.literal(2n)
const tru = z.literal(true)
const terrificSymbol = Symbol("terrific")
const terrific = z.literal(terrificSymbol)
tuna.value
Schema
import { Schema as S } from "@effect/schema"
const tuna = S.Literal("tuna")
const twelve = S.Literal(12)
const twobig = S.Literal(2n)
const tru = S.Literal(true)
const terrificSymbol = Symbol("terrific")
const terrific = S.UniqueSymbolFromSelf(terrificSymbol)
tuna.literals
Strings
Zod
z.string().max(5)
z.string().min(5)
z.string().length(5)
z.string().email()
z.string().url()
z.string().emoji()
z.string().uuid()
z.string().nanoid()
z.string().cuid()
z.string().cuid2()
z.string().ulid()
z.string().regex(regex)
z.string().includes(string)
z.string().startsWith(string)
z.string().endsWith(string)
z.string().datetime()
z.string().date()
z.string().time()
z.string().duration()
z.string().ip()
z.string().base64()
z.string().trim()
z.string().toLowerCase()
z.string().toUpperCase()
Schema
import { Schema as S } from "@effect/schema"
S.String.pipe(S.maxLength(5))
S.String.pipe(S.minLength(5))
S.String.pipe(S.length(5))
S.UUID
S.ULID
S.String.pipe(S.pattern(regex))
S.String.pipe(S.includes(string))
S.String.pipe(S.startsWith(string))
S.String.pipe(S.endsWith(string))
S.Trim
S.Lowercase
S.Uppercase
You can customize some common error messages when creating a string schema.
Zod
const name = z.string({
required_error: "Name is required",
invalid_type_error: "Name must be a string"
})
Schema
const name = S.String.annotations({
message: () => "Name must be a string"
})
When using validation methods, you can pass in an additional argument to provide a custom error message.
Zod
z.string().min(5, { message: "Must be 5 or more characters long" })
Schema
S.String.pipe(
S.minLength(5, { message: () => "Must be 5 or more characters long" })
)
Datetimes
No equivalent.
Dates
Zod
const date = z.string().date()
date.parse("2020-01-01")
date.parse("2020-1-1")
date.parse("2020-01-32")
Schema
import { Schema as S } from "@effect/schema"
S.decodeUnknownSync(S.Date)("2020-01-01")
S.decodeUnknownSync(S.Date)("2020-1-1")
S.decodeUnknownSync(S.Date)("2020-01-32")
Times
No equivalent.
IP addresses
No equivalent.
Numbers
Zod
z.number().gt(5)
z.number().gte(5)
z.number().lt(5)
z.number().lte(5)
z.number().int()
z.number().positive()
z.number().nonnegative()
z.number().negative()
z.number().nonpositive()
z.number().multipleOf(5)
z.number().finite()
z.number().safe()
Schema
import { Schema as S } from "@effect/schema"
S.Number.pipe(S.greaterThan(5))
S.Number.pipe(S.greaterThanOrEqualTo(5))
S.Number.pipe(S.lessThan(5))
S.Number.pipe(S.lessThanOrEqualTo(5))
S.Number.pipe(S.int())
S.Number.pipe(S.positive())
S.Number.pipe(S.nonNegative())
S.Number.pipe(S.negative())
S.Number.pipe(S.nonPositive())
S.Number.pipe(S.multipleOf(5))
S.Number.pipe(S.finite())
Optionally, you can pass in a second argument to provide a custom error message.
Zod
z.number().lte(5, { message: "this👏is👏too👏big" })
Schema
S.Number.pipe(S.lessThanOrEqualTo(5, { message: () => "this👏is👏too👏big" }))
BigInts
Zod
z.bigint().gt(5n)
z.bigint().gte(5n)
z.bigint().lt(5n)
z.bigint().lte(5n)
z.bigint().positive()
z.bigint().nonnegative()
z.bigint().negative()
z.bigint().nonpositive()
z.bigint().multipleOf(5n)
Schema
import { Schema as S } from "@effect/schema"
S.BigInt.pipe(S.greaterThanBigInt(5n))
S.BigInt.pipe(S.greaterThanOrEqualToBigInt(5n))
S.BigInt.pipe(S.lessThanBigInt(5n))
S.BigInt.pipe(S.lessThanOrEqualToBigInt(5n))
S.BigInt.pipe(S.positiveBigInt())
S.BigInt.pipe(S.nonNegativeBigInt())
S.BigInt.pipe(S.negativeBigInt())
S.BigInt.pipe(S.nonPositiveBigInt())
Booleans
Zod
const isActive = z.boolean({
required_error: "isActive is required",
invalid_type_error: "isActive must be a boolean"
})
Schema
const isActive = S.Boolean.annotations({
message: () => "isActive must be a boolean"
})
Native enums
Zod
enum Fruits {
Apple,
Banana
}
const FruitEnum = z.nativeEnum(Fruits)
type FruitEnum = z.infer<typeof FruitEnum>
FruitEnum.parse(Fruits.Apple)
FruitEnum.parse(Fruits.Banana)
FruitEnum.parse(0)
FruitEnum.parse(1)
FruitEnum.parse(3)
Schema
enum Fruits {
Apple,
Banana
}
const FruitEnum = S.Enums(Fruits)
type FruitEnum = S.Schema.Type<typeof FruitEnum>
S.decodeUnknownSync(FruitEnum)(Fruits.Apple)
S.decodeUnknownSync(FruitEnum)(Fruits.Banana)
S.decodeUnknownSync(FruitEnum)(0)
S.decodeUnknownSync(FruitEnum)(1)
S.decodeUnknownSync(FruitEnum)(3)
Optionals
Zod
const user = z.object({
username: z.string().optional()
})
type C = z.infer<typeof user>
Schema
const user = S.Struct({
username: S.optional(S.String)
})
type C = S.Schema.Type<typeof user>
Nullables
Zod
const nullableString = z.nullable(z.string())
nullableString.parse("asdf")
nullableString.parse(null)
Schema
const nullableString = S.NullOr(S.String)
S.decodeUnknownSync(nullableString)("asdf")
S.decodeUnknownSync(nullableString)(null)
Objects
Zod
const Dog = z.object({
name: z.string(),
age: z.number()
})
type Dog = z.infer<typeof Dog>
type Dog = {
name: string
age: number
}
Schema
const Dog = S.Struct({
name: S.String,
age: S.Number
})
type Dog = S.Schema.Type<typeof Dog>
type Dog = {
readonly name: string
readonly age: number
}
shape
Zod
Dog.shape.name
Dog.shape.age
Schema
Dog.fields.name
Dog.fields.age
keyof
Zod
const keySchema = Dog.keyof()
keySchema
Schema
const keySchema = S.keyof(Dog)
extend
Zod
const DogWithBreed = Dog.extend({
breed: z.string()
})
Schema
const DogWithBreed = Dog.pipe(
S.extend(
S.Struct({
breed: S.String
})
)
)
const DogWithBreed = S.Struct({
...Dog.fields,
breed: S.String
})
pick / omit
Zod
const Recipe = z.object({
id: z.string(),
name: z.string(),
ingredients: z.array(z.string())
})
const JustTheName = Recipe.pick({ name: true })
const NoIDRecipe = Recipe.omit({ id: true })
Schema
const Recipe = S.Struct({
id: S.String,
name: S.String,
ingredients: S.Array(S.String)
})
const JustTheName = Recipe.pick("name")
const NoIDRecipe = Recipe.omit("id")
partial
Zod
const user = z.object({
email: z.string(),
username: z.string()
})
const partialUser = user.partial()
Schema
const user = S.Struct({
email: S.String,
username: S.String
})
const partialUser = S.partial(user)
deepPartial
No equivalent
required
Zod
const user = z
.object({
email: z.string(),
username: z.string()
})
.partial()
const requiredUser = user.required()
Schema
const user = S.partial(
S.Struct({
email: S.String,
username: S.String
})
)
const requiredUser = S.required(user)
passthrough
Zod
const person = z.object({
name: z.string()
})
person.parse({
name: "bob dylan",
extraKey: 61
})
person.passthrough().parse({
name: "bob dylan",
extraKey: 61
})
Schema
const person = S.Struct({
name: S.String
})
S.decodeUnknownSync(person)(
{
name: "bob dylan",
extraKey: 61
},
{ onExcessProperty: "preserve" }
)
strict
Zod
const person = z
.object({
name: z.string()
})
.strict()
person.parse({
name: "bob dylan",
extraKey: 61
})
Schema
const person = S.Struct({
name: S.String
})
S.decodeUnknownSync(person)(
{
name: "bob dylan",
extraKey: 61
},
{ onExcessProperty: "error" }
)
catchall
Zod
const person = z
.object({
name: z.string()
})
.catchall(z.string())
person.parse({
name: "bob dylan",
validExtraKey: "foo"
})
person.parse({
name: "bob dylan",
validExtraKey: false
})
Schema
const person = S.Struct(
{
name: S.String
},
S.Record({ key: S.String, value: S.String })
)
S.decodeUnknownSync(person)({
name: "bob dylan",
validExtraKey: "foo"
})
S.decodeUnknownSync(person)({
name: "bob dylan",
validExtraKey: true
})
Arrays
Zod
const stringArray = z.array(z.string())
Schema
const stringArray = S.Array(S.String)
element
Zod
stringArray.element
Schema
stringArray.value
nonempty
Zod
const nonEmptyStrings = z.string().array().nonempty()
nonEmptyStrings.parse([])
nonEmptyStrings.parse(["Ariana Grande"])
Schema
const nonEmptyStrings = S.NonEmptyArray(S.String)
S.decodeUnknownSync(nonEmptyStrings)([])
S.decodeUnknownSync(nonEmptyStrings)(["Ariana Grande"])
min / max / length
Zod
z.string().array().min(5)
z.string().array().max(5)
z.string().array().length(5)
Schema
S.Array(S.String).pipe(S.minItems(5))
S.Array(S.String).pipe(S.maxItems(5))
S.Array(S.String).pipe(S.itemsCount(5))
Tuples
Zod
const athleteSchema = z.tuple([
z.string(),
z.number(),
z.object({
pointsScored: z.number()
})
])
type Athlete = z.infer<typeof athleteSchema>
Schema
const athleteSchema = S.Tuple(
S.String,
S.Number,
S.Struct({
pointsScored: S.Number
})
)
type Athlete = S.Schema.Type<typeof athleteSchema>
A variadic ("rest") argument can be added with the .rest method.
Zod
const variadicTuple = z.tuple([z.string()]).rest(z.number())
const result = variadicTuple.parse(["hello", 1, 2, 3])
Schema
const variadicTuple = S.Tuple([S.String], S.Number)
const result = S.decodeUnknownSync(variadicTuple)(["hello", 1, 2, 3])
Unions
Zod
const stringOrNumber = z.union([z.string(), z.number()])
stringOrNumber.parse("foo")
stringOrNumber.parse(14)
Schema
const stringOrNumber = S.Union(S.String, S.Number)
S.decodeUnknownSync(stringOrNumber)("foo")
S.decodeUnknownSync(stringOrNumber)(14)
Discriminated unions
No equivalent needed as discriminated unions are automatically detected.
Records
Zod
const User = z.object({ name: z.string() })
const UserStore = z.record(z.string(), User)
type UserStore = z.infer<typeof UserStore>
Schema
const User = S.Struct({ name: S.String })
const UserStore = S.Record({ key: S.String, value: User })
type UserStore = S.Schema.Type<typeof UserStore>
Maps
Zod
const stringNumberMap = z.map(z.string(), z.number())
type StringNumberMap = z.infer<typeof stringNumberMap>
Schema
const stringNumberMap = S.Map({ key: S.String, value: S.Number })
type StringNumberMap = S.Schema.Type<typeof stringNumberMap>
Sets
Zod
const numberSet = z.set(z.number())
type NumberSet = z.infer<typeof numberSet>
Schema
const numberSet = S.Set(S.Number)
type NumberSet = S.Schema.Type<typeof numberSet>
Intersections
No equivalent.
Recursive types
Zod
const baseCategorySchema = z.object({
name: z.string()
})
type Category = z.infer<typeof baseCategorySchema> & {
subcategories: Category[]
}
const categorySchema: z.ZodType<Category> = baseCategorySchema.extend({
subcategories: z.lazy(() => categorySchema.array())
})
Schema
const baseCategorySchema = S.Struct({
name: S.String
})
type Category = S.Schema.Type<typeof baseCategorySchema> & {
readonly subcategories: ReadonlyArray<Category>
}
const categorySchema: S.Schema<Category> = S.Struct({
...baseCategorySchema.fields,
subcategories: S.suspend(() => S.Array(categorySchema))
})
Promises
No equivalent.
Instanceof
Zod
class Test {
name: string = "name"
}
const TestSchema = z.instanceof(Test)
const blob: any = "whatever"
TestSchema.parse(new Test())
TestSchema.parse(blob)
Schema
class Test {
name: string = "name"
}
const TestSchema = S.instanceOf(Test)
const blob: any = "whatever"
S.decodeUnknownSync(TestSchema)(new Test())
S.decodeUnknownSync(TestSchema)(blob)
Functions
No equivalent.
Preprocess
No equivalent.
Custom schemas
Zod
z.custom
Schema
S.declare
function
refine / superRefine
Zod
.refine()
/ .superRefine()
methods
Schema
S.filter
/ S.filterEffect
functions
transform
Zod
.transform()
method
Schema
S.transform
/ S.transformOrFail
functions
describe
Zod
const documentedString = z
.string()
.describe("A useful bit of text, if you know what to do with it.")
documentedString.description
Schema
import { AST, Schema as S } from "@effect/schema"
const documentedString = S.String.annotations({
description: "A useful bit of text, if you know what to do with it."
})
console.log(AST.getDescriptionAnnotation(documentedString.ast))
nullish
Zod
const nullishString = z.string().nullish()
Schema
const nullishString = S.NullishOr(S.String)
brand
Zod
const Cat = z.object({ name: z.string() }).brand<"Cat">()
Schema
const Cat = S.Struct({ name: S.String }).pipe(S.brand("Cat"))
readonly
No equivalent as it's the default behavior.
API Reference
License
The MIT License (MIT)
Contributing Guidelines
Thank you for considering contributing to our project! Here are some guidelines to help you get started:
Reporting Bugs
If you have found a bug, please open an issue on our issue tracker and provide as much detail as possible. This should include:
- A clear and concise description of the problem
- Steps to reproduce the problem
- The expected behavior
- The actual behavior
- Any relevant error messages or logs
Suggesting Enhancements
If you have an idea for an enhancement or a new feature, please open an issue on our issue tracker and provide as much detail as possible. This should include:
- A clear and concise description of the enhancement or feature
- Any potential benefits or use cases
- Any potential drawbacks or trade-offs
Pull Requests
We welcome contributions via pull requests! Here are some guidelines to help you get started:
- Fork the repository and clone it to your local machine.
- Create a new branch for your changes:
git checkout -b my-new-feature
- Ensure you have the required dependencies installed by running:
pnpm install
(assuming pnpm version 8.x
). - Make your desired changes and, if applicable, include tests to validate your modifications.
- Run the following commands to ensure the integrity of your changes:
pnpm check
: Verify that the code compiles.pnpm test
: Execute the tests.pnpm circular
: Confirm there are no circular imports.pnpm lint
: Check for code style adherence (if you happen to encounter any errors during this process, you can add the --fix
option to automatically fix some of these style issues).pnpm dtslint
: Run type-level tests.pnpm docgen
: Update the automatically generated documentation.
- Create a changeset for your changes: before committing your changes, create a changeset to document the modifications. This helps in tracking and communicating the changes effectively. To create a changeset, run the following command:
pnpm changeset
. - Commit your changes: after creating the changeset, commit your changes with a descriptive commit message:
git commit -am 'Add some feature'
. - Push your changes to your fork:
git push origin my-new-feature
. - Open a pull request against our
main
branch.
Pull Request Guidelines
- Please make sure your changes are consistent with the project's existing style and conventions.
- Please write clear commit messages and include a summary of your changes in the pull request description.
- Please make sure all tests pass and add new tests as necessary.
- If your change requires documentation, please update the relevant documentation.
- Please be patient! We will do our best to review your pull request as soon as possible.
Credits
This library was inspired by the following projects:
License
By contributing to this project, you agree that your contributions will be licensed under the project's MIT License.