@effect/schema
v0.66.14
Published
Modeling the schema of data structures as first-class values
Downloads
1,262,917
Keywords
Readme
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 isnever
(default if not explicitly provided), it means the schema has no requirements.
Examples
Schema<string>
(defaulted toSchema<string, string, never>
) represents a schema that decodes tostring
, encodes tostring
, and has no requirements.Schema<number, string>
(defaulted toSchema<number, string, never>
) represents a schema that decodes tonumber
fromstring
, encodes anumber
to astring
, and has no requirements.
[!NOTE] In the Effect ecosystem, you may often encounter the type parameters of
Schema
abbreviated asA
,I
, andR
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 astring
.Decoding: Following the successful check, we proceed to convert the
string
into aDate
. 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 aDate
.Encoding: Following the successful check, we proceed to convert the
Date
into astring
. 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.
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.
Credits
This library was inspired by the following projects:
Requirements
- TypeScript 5.0 or newer
- The
strict
flag enabled in yourtsconfig.json
file - The
exactOptionalPropertyTypes
flag enabled in yourtsconfig.json
file{ // ... "compilerOptions": { // ... "strict": true, "exactOptionalPropertyTypes": true } }
- 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)- fast-check package (peer dependency)
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 * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.optional(S.String.pipe(S.nonEmpty()), {
exact: true
})
})
/*
type Type = {
readonly name?: string; // the type is strict (no `| undefined`)
}
*/
type Type = S.Schema.Type<typeof Person>
S.decodeSync(Person)({ name: undefined })
/*
TypeScript Error:
Argument of type '{ name: undefined; }' is not assignable to parameter of type '{ readonly name?: string; }' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Types of property 'name' are incompatible.
Type 'undefined' is not assignable to type 'string'.ts(2379)
*/
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 * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.optional(S.String.pipe(S.nonEmpty()), {
exact: true
})
})
/*
type Type = {
readonly name?: string | undefined; // the type is widened to string | undefined
}
*/
type Type = S.Schema.Type<typeof Person>
S.decodeSync(Person)({ name: undefined }) // No type error, but a decoding failure occurs
/*
Error: { name?: a non empty string }
└─ ["name"]
└─ a non empty string
└─ From side refinement failure
└─ Expected a string, actual 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)- fast-check 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 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 * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.String,
age: S.Number
})
[!NOTE] It's important to note that by default, most constructors exported by
@effect/schema
returnreadonly
types. For instance, in thePerson
schema above, the resulting type would be{ readonly name: string; readonly age: number; }
.
Extracting Inferred Types
Type
After you've defined a Schema<A, I, R>
, you can extract the inferred type A
that represents the data described by the schema using the Schema.Type
utility.
For instance you can extract the inferred type of a Person
object as demonstrated below:
import * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.String,
age: S.NumberFromString
})
interface Person extends S.Schema.Type<typeof Person> {}
/*
Equivalent to:
interface Person {
readonly name: string;
readonly age: number;
}
*/
Alternatively, you can define the Person
type using the type
keyword:
type Person = S.Schema.Type<typeof Person>
/*
Equivalent to:
type Person {
readonly name: string;
readonly age: number;
}
*/
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.
import * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.String,
age: S.NumberFromString
})
type PersonEncoded = S.Schema.Encoded<typeof Person>
/*
type PersonEncoded = {
readonly name: string;
readonly age: string;
}
*/
Context
You can also extract the inferred type R
that represents the context described by the schema using the Schema.Context
utility:
import * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.String,
age: S.NumberFromString
})
// type PersonContext = never
type PersonContext = S.Schema.Context<typeof Person>
Advanced extracting Inferred Types
To create a schema with an opaque type, you can use the following technique that re-declares the schema:
import * as S from "@effect/schema/Schema"
const _Person = S.Struct({
name: S.String,
age: S.Number
})
interface Person extends S.Schema.Type<typeof _Person> {}
// Re-declare the schema to create a schema with an opaque type
const Person: S.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 * as S from "@effect/schema/Schema"
const _Person = S.Struct({
name: S.String,
age: S.NumberFromString
})
interface Person extends S.Schema.Type<typeof _Person> {}
interface PersonEncoded extends S.Schema.Encoded<typeof _Person> {}
// Re-declare the schema to create a schema with an opaque type
const Person: S.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 anOption
type.decodeUnknownEither
: Decodes a value and returns anEither
type.decodeUnknownPromise
: Decodes a value and returns aPromise
.decodeUnknown
: Decodes a value and returns anEffect
.
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 * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.String,
age: S.Number
})
// Simulate an unknown input
const input: unknown = { name: "Alice", age: 30 }
console.log(S.decodeUnknownSync(Person)(input))
// Output: { name: 'Alice', age: 30 }
console.log(S.decodeUnknownSync(Person)(null))
/*
throws:
Error: Expected { name: string; age: number }, actual null
*/
Example (Using decodeUnknownEither
)
Now, let's see how to use the decodeUnknownEither
function, which returns an Either
type representing success or failure.
import * as S from "@effect/schema/Schema"
import * as Either from "effect/Either"
const Person = S.Struct({
name: S.String,
age: S.Number
})
const decode = S.decodeUnknownEither(Person)
// Simulate an unknown input
const input: unknown = { name: "Alice", age: 30 }
const result1 = decode(input)
if (Either.isRight(result1)) {
console.log(result1.right)
/*
Output:
{ name: "Alice", age: 30 }
*/
}
const result2 = decode(null)
if (Either.isLeft(result2)) {
console.log(result2.left)
/*
Output:
{
_id: 'ParseError',
message: 'Expected { name: string; age: number }, actual null'
}
*/
}
The decode
function returns an Either<A, ParseError>
, where ParseError
is defined as follows:
interface ParseError {
readonly _tag: "ParseError"
readonly error: 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 * as S from "@effect/schema/Schema"
import * as Effect from "effect/Effect"
const PersonId = S.Number
const Person = S.Struct({
id: PersonId,
name: S.String,
age: S.Number
})
const asyncSchema = S.transformOrFail(PersonId, Person, {
// Simulate an async transformation
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 = S.decodeUnknownEither(asyncSchema)
console.log(JSON.stringify(syncParsePersonId(1), null, 2))
/*
Output:
{
"_id": "Either",
"_tag": "Left",
"left": {
"_id": "ParseError",
"message": "(number <-> { id: number; name: string; age: number })\n└─ cannot be be resolved synchronously, this is caused by using runSync on an effect that performs async work"
}
}
*/
const asyncParsePersonId = S.decodeUnknown(asyncSchema)
Effect.runPromise(asyncParsePersonId(1)).then(console.log)
/*
Output:
{ id: 1, name: 'name', age: 18 }
*/
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.
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 * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.String,
age: S.Number
})
console.log(
S.decodeUnknownSync(Person)({
name: "Bob",
age: 40,
email: "[email protected]"
})
)
/*
Output:
{ name: 'Bob', age: 40 }
*/
S.decodeUnknownSync(Person)(
{
name: "Bob",
age: 40,
email: "[email protected]"
},
{ onExcessProperty: "error" }
)
/*
throws
Error: { name: string; age: number }
└─ ["email"]
└─ is unexpected, expected "name" | "age"
*/
If you want to allow excess properties to remain, you can use onExcessProperty
set to "preserve"
:
import * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.String,
age: S.Number
})
console.log(
S.decodeUnknownSync(Person)(
{
name: "Bob",
age: 40,
email: "[email protected]"
},
{ onExcessProperty: "preserve" }
)
)
/*
{ email: '[email protected]', name: 'Bob', age: 40 }
*/
[!NOTE] The
onExcessProperty
anderror
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 * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.String,
age: S.Number
})
S.decodeUnknownSync(Person)(
{
name: "Bob",
age: "abc",
email: "[email protected]"
},
{ errors: "all", onExcessProperty: "error" }
)
/*
throws
Error: { name: string; age: number }
├─ ["email"]
│ └─ is unexpected, expected "name" | "age"
└─ ["age"]
└─ Expected a number, actual "abc"
*/
[!NOTE] The
onExcessProperty
anderror
options also affect encoding.
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 anOption
type.encodeEither
: Encodes data and returns anEither
type representing success or failure.encodePromise
: Encodes data and returns aPromise
.encode
: Encodes data and returns anEffect
.
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"
// Age is a schema that can decode a string to a number and encode a number to a string
const Age = S.NumberFromString
const Person = S.Struct({
name: S.NonEmpty,
age: Age
})
console.log(S.encodeSync(Person)({ name: "Alice", age: 30 }))
// Output: { name: 'Alice', age: '30' }
console.log(S.encodeSync(Person)({ name: "", age: 30 }))
/*
throws:
Error: { name: NonEmpty; age: NumberFromString }
└─ ["name"]
└─ NonEmpty
└─ Predicate refinement failure
└─ Expected NonEmpty (a non empty string), actual ""
*/
Note that during encoding, the number value 30
was converted to a string "30"
.
[!NOTE] The
onExcessProperty
anderror
options also affect encoding.
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 * as S from "@effect/schema/Schema"
import { formatError } from "@effect/schema/TreeFormatter"
import * as Either from "effect/Either"
const Person = S.Struct({
name: S.String,
age: S.Number
})
const result = S.decodeUnknownEither(Person)({})
if (Either.isLeft(result)) {
console.error("Decoding failed:")
console.error(formatError(result.left))
}
/*
Decoding failed:
{ name: string; age: number }
└─ ["name"]
└─ is missing
*/
In this example, the tree error message is structured as follows:
{ name: string; age: number }
represents the schema, providing a visual representation of the expected structure. This can be customized using annotations, such as setting theidentifier
annotation.["name"]
indicates the offending property, in this case, the"name"
property.is missing
represents the specific error for the"name"
property.
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 { ParseIssue } from "@effect/schema/ParseResult"
import * as S from "@effect/schema/Schema"
const getOrderItemId = ({ actual }: ParseIssue) => {
if (S.is(S.Struct({ id: S.String }))(actual)) {
return `OrderItem with id: ${actual.id}`
}
}
const OrderItem = S.Struct({
id: S.String,
name: S.String,
price: S.Number
}).annotations({
identifier: "OrderItem",
parseIssueTitle: getOrderItemId
})
const getOrderId = ({ actual }: ParseIssue) => {
if (S.is(S.Struct({ id: S.Number }))(actual)) {
return `Order with id: ${actual.id}`
}
}
const Order = S.Struct({
id: S.Number,
name: S.String,
items: S.Array(OrderItem)
}).annotations({
identifier: "Order",
parseIssueTitle: getOrderId
})
const decode = S.decodeUnknownSync(Order, { errors: "all" })
// No id available, so the `identifier` annotation is used as the title
decode({})
/*
throws
Error: Order
├─ ["id"]
│ └─ is missing
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ is missing
*/
// An id is available, so the `parseIssueTitle` annotation is used as the title
decode({ id: 1 })
/*
throws
Error: Order with id: 1
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ is missing
*/
decode({ id: 1, items: [{ id: "22b", price: "100" }] })
/*
throws
Error: Order with id: 1
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ ReadonlyArray<OrderItem>
└─ [0]
└─ OrderItem with id: 22b
├─ ["name"]
│ └─ is missing
└─ ["price"]
└─ Expected a number, actual "100"
*/
In the examples above, we can see how the parseIssueTitle
annotation helps provide meaningful error messages when decoding fails.
ArrayFormatter
The ArrayFormatter
is an alternative way to format errors, presenting them as an array of issues. Each issue contains properties such as _tag
, path
, and message
.
Here's an example of how it works:
import { formatError } from "@effect/schema/ArrayFormatter"
import * as S from "@effect/schema/Schema"
import * as Either from "effect/Either"
const Person = S.Struct({
name: S.String,
age: S.Number
})
const result = S.decodeUnknownEither(Person)(
{ name: 1, foo: 2 },
{ errors: "all", onExcessProperty: "error" }
)
if (Either.isLeft(result)) {
console.error("Parsing failed:")
console.error(formatError(result.left))
}
/*
Parsing failed:
[
{
_tag: 'Unexpected',
path: [ 'foo' ],
message: 'is unexpected, expected "name" | "age"'
},
{
_tag: 'Type',
path: [ 'name' ],
message: 'Expected a string, actual 1'
},
{ _tag: 'Missing', path: [ 'age' ], message: 'is missing' }
]
*/
Assertions
The is
function provided by the @effect/schema/Schema
module represents a way of verifying that a value conforms to a given Schema
. is
is a refinement that takes a value of type unknown
as an argument and returns a boolean
indicating whether or not the value conforms to the Schema
.
import * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.String,
age: S.Number
})
/*
const isPerson: (a: unknown, options?: ParseOptions | undefined) => a is {
readonly name: string;
readonly age: number;
}
*/
const isPerson = S.is(Person)
console.log(isPerson({ name: "Alice", age: 30 })) // true
console.log(isPerson(null)) // false
console.log(isPerson({})) // false
The asserts
function takes a Schema
and returns a function that takes an input value and checks if it matches the schema. If it does not match the schema, it throws an error with a comprehensive error message.
import * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.String,
age: S.Number
})
// const assertsPerson: (input: unknown, options?: ParseOptions) => asserts input is { readonly name: string; readonly age: number; }
const assertsPerson: S.Schema.ToAsserts<typeof Person> = S.asserts(Person)
try {
assertsPerson({ name: "Alice", age: "30" })
} catch (e) {
console.error("The input does not match the schema:")
console.error(e)
}
/*
The input does not match the schema:
Error: { name: string; age: number }
└─ ["age"]
└─ Expected a number, actual "30"
*/
// this will not throw an error
assertsPerson({ name: "Alice", age: 30 })
Using fast-check Arbitraries
The make
function provided by the @effect/schema/Arbitrary
module represents a way of generating random values that conform to a given Schema
. This can be useful for testing purposes, as it allows you to generate random test data that is guaranteed to be valid according to the Schema
.
import { Arbitrary, FastCheck, Schema } from "@effect/schema"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.String.pipe(Schema.compose(Schema.NumberFromString), Schema.int())
})
/*
FastCheck.Arbitrary<{
readonly name: string;
readonly age: number;
}>
*/
const PersonArbitraryType = Arbitrary.make(Person)
console.log(FastCheck.sample(PersonArbitraryType, 2))
/*
Output:
[ { name: 'iP=!', age: -6 }, { name: '', age: 14 } ]
*/
/*
Arbitrary for the "Encoded" type:
FastCheck.Arbitrary<{
readonly name: string;
readonly age: string;
}>
*/
const PersonArbitraryEncoded = Arbitrary.make(Schema.encodedSchema(Person))
console.log(FastCheck.sample(PersonArbitraryEncoded, 2))
/*
Output:
[ { name: '{F', age: '$"{|' }, { name: 'nB}@BK', age: '^V+|W!Z' } ]
*/
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))
// Output: [ 1139348969, 749305462 ]
[!WARNING] Note that when customizing any schema, any filter preceding the customization will be lost, only filters following the customization will be respected.
Example
import { Arbitrary, FastCheck, Schema } from "@effect/schema"
const bad = Schema.Number.pipe(Schema.positive()).annotations({
arbitrary: () => (fc) => fc.integer()
})
console.log(FastCheck.sample(Arbitrary.make(bad), 2))
// Example Output: [ -1600163302, -6 ]
const good = Schema.Number.annotations({
arbitrary: () => (fc) => fc.integer()
}).pipe(Schema.positive())
console.log(FastCheck.sample(Arbitrary.make(good), 2))
// Example Output: [ 7, 1518247613 ]
Pretty print
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 * as Pretty from "@effect/schema/Pretty"
import * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.String,
age: S.Number
})
const PersonPretty = Pretty.make(Person)
// returns a string representation of the object
console.log(PersonPretty({ name: "Alice", age: 30 }))
/*
Output:
'{ "name": "Alice", "age": 30 }'
*/
Customizations
You can customize the output using the pretty
annotation:
import * as Pretty from "@effect/schema/Pretty"
import * as S from "@effect/schema/Schema"
const schema = S.Number.annotations({
pretty: () => (n) => `my format: ${n}`
})
console.log(Pretty.make(schema)(1)) // my format: 1
Generating JSON Schemas
The make
function from the @effect/schema/JSONSchema
module enables you to create a JSON Schema based on a defined schema:
import * as JSONSchema from "@effect/schema/JSONSchema"
import * as S from "@effect/schema/Schema"
const Person = S.Struct({
name: S.String,
age: S.Number
})
const jsonSchema = JSONSchema.make(Person)
console.log(JSON.stringify(jsonSchema, null, 2))
/*
Output:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": [
"name",
"age"
],
"properties": {
"name": {
"type": "string",
"description": "a string",
"title": "string"
},
"age": {
"type": "number",
"description": "a number",
"title": "number"
}
},
"additionalProperties": false
}
*/
In this example, we have created a schema for a "Person" with a name (a string) and an age (a number). We then use the JSONSchema.make
function to generate the corresponding JSON Schema.
Identifier Annotations
You can enhance your schemas with identifier
annotations. If you do, your schema will be included within a "definitions" object property on the root and referenced from there:
import * as JSONSchema from "@effect/schema/JSONSchema"
import * as S from "@effect/schema/Schema"
const Name = S.String.annotations({ identifier: "Name" })
const Age = S.Number.annotations({ identifier: "Age" })
const Person = S.Struct({
name: Name,
age: Age
})
const jsonSchema = JSONSchema.make(Person)
console.log(JSON.stringify(jsonSchema, null, 2))
/*
Output:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": [
"name",
"age"
],
"properties": {
"name": {
"$ref": "#/$defs/Name"
},
"age": {
"$ref": "#/$defs/Age"
}
},
"additionalProperties": false,
"$defs": {
"Name": {
"type": "string",
"description": "a string",
"title": "string"
},
"Age": {
"type": "number",
"description": "a number",
"title": "number"
}
}
}
*/
This technique helps organize your JSON Schema by creating separate definitions for each identifier annotated schema, making it more readable and maintainable.
Recursive and Mutually Recursive Schemas
Recursive and mutually recursive schemas are supported, but in these cases, identifier annotations are required:
import * as JSONSchema from "@effect/schema/JSONSchema"
import * as S from "@effect/schema/Schema"
interface Category {
readonly name: string
readonly categories: ReadonlyArray<Category>
}
const schema: S.Schema<Category> = S.Struct({
name: S.String,
categories: S.Array(S.suspend(() => schema))
}).annotations({ identifier: "Category" })
const jsonSchema = JSONSchema.make(schema)
console.log(JSON.stringify(jsonSchema, null, 2))
/*
Output:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$ref": "#/$defs/Category",
"$defs": {
"Category": {
"type": "object",
"required": [
"name",
"categories"
],
"properties": {
"name": {
"type": "string",
"description": "a string",
"title": "string"
},
"categories": {
"type": "array",
"items": {
"$ref": "#/$defs/Category"
}
}
},
"additionalProperties": false
}
}
}
*/
In the example above, we define a schema for a "Category" that can contain a "name" (a string) and an array of nested "categories." To support recursive definitions, we use the S.suspend
function and identifier annotations to name our schema.
This ensures that the JSON Schema properly handles the recursive structure and creates distinct definitions for each annotated schema, improving readability and maintainability.
JSON Schema Annotations
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 * as JSONSchema from "@effect/schema/JSONSchema"
import * as S from "@effect/schema/Schema"
// Simulate one or more refinements
const Positive = S.Number.pipe(
S.filter((n) => n > 0, {
jsonSchema: { minimum: 0 }
})
)
const schema = Positive.pipe(
S.filter((n) => n <= 10, {
jsonSchema: { maximum: 10 }
})
)
console.log(JSONSchema.make(schema))
/*
Output:
{
'$schema': 'http://json-schema.org/draft-07/schema#',
type: 'number',
description: 'a number',
title: 'number',
minimum: 0,
maximum: 10
}
*/
As seen in the example, the JSON Schema annotations are merged with the base JSON Schema from S.Number
. This approach helps handle multiple refinements while maintaining clarity in your code.
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 * as S from "@effect/schema/Schema"
import * as Equivalence from "@effect/schema/Equivalence"
const Person = S.Struct({
name: S.String,
age: S.Number
})
// $ExpectType Equivalence<{ readonly name: string; readonly age: 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 })) // Output: true
console.log(PersonEquivalence(john, alice)) // Output: false
Customizations
You can customize the output using the equivalence
annotation:
import * as Equivalence from "@effect/schema/Equivalence"
import * as S from "@effect/schema/Schema"
const schema = S.String.annotations({
equivalence: () => (a, b) => a.at(0) === b.at(0)
})
console.log(Equivalence.make(schema)("aaa", "abb")) // Output: true
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 * as S from "@effect/schema/Schema"
// API interface
interface Age extends S.Schema<number> {}
const Age: Age = S.Number.pipe(S.between(0, 100))
// type AgeType = number
type AgeType = S.Schema.Type<typeof Age>
// type AgeEncoded = number
type AgeEncoded = S.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 * as S from "@effect/schema/Schema"
// const number: S.$Number
S.Number
// const never: S.Never
S.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 TypeScriptNumber
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 * as S from "@effect/schema/Schema"
// API interface
export interface pair<S extends S.Schema.Any>
extends S.Schema<
readonly [S.Schema.Type<S>, S.Schema.Type<S>],
readonly [S.Schema.Encoded<S>, S.Schema.Encoded<S>],
S.Schema.Context<S>
> {}
// API
export const pair = <S extends S.Schema.Any>(schema: S): pair<S> =>
S.Tuple(S.asSchema(schema), S.asSchema(schema))
[!NOTE] The
S.Schema.Any
helper represents any schema, except fornever
. For more information on theasSchema
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<S.$Number>
const Coords = pair(S.Number)
In hover, we simply see pair<S.$Number>
instead of the verbose:
// const Coords: S.Schema<readonly [number, number], readonly [number, number], never>
const Coords = S.Tuple(S.Number, S.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<S.$Number>
const Coords = pair(S.Number)
// const NonOpaqueCoords: S.Schema<readonly [number, number], readonly [number, number], never>
const NonOpaqueCoords = S.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 * as S from "@effect/schema/Schema"
/*
const Person: S.Struct<{
name: S.$String;
age: S.$Number;
}>
*/
const Person = S.Struct({
name: S.String,
age: S.Number
})
In hover, we simply see:
const Person: S.Struct<{
name: S.$String
age: S.$Number
}>
instead of the verbose:
const Person: S.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 * as S from "@effect/schema/Schema"
// const myliterals: S.Literal<["A", "B"]>
const myliterals = S.Literal("A", "B")
// literals: readonly ["A", "B"]
myliterals.literals
console.log(myliterals.literals) // Output: [ 'A', 'B' ]
Example (exposed fields)
Similarly to what we've seen for literals, when we define a struct, we can retrieve its fields
:
import * as S from "@effect/schema/Schema"
/*
const Person: S.Struct<{
name: S.$String;
age: S.$Number;
}>
*/
const Person = S.Struct({
name: S.String,
age: S.Number
})
/*
fields: {
readonly name: S.$String;
readonly age: S.$Number;
}
*/
Person.fields
console.log(Person.fields)
/*
{
name: Schema {
ast: StringKeyword { _tag: 'StringKeyword', annotations: [Object] },
...
},
age: Schema {
ast: NumberKeyword { _tag: 'NumberKeyword', annotations: [Object] },
...
}
}
*/
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"
const Person = S.Struct({
name: S.String,
age: S.Number
})
/*
const PersonWithId: S.Struct<{
id: S.$Number;
name: S.$String;
age: S.$Number;
}>
*/
const PersonWithId = S.Struct({
...Person.fields,
id: S.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 * as S from "@effect/schema/Schema"
// ------------------------
// array value
// ------------------------
// value: S.$String
S.Array(S.String).value
// ------------------------
// record key and value
// ------------------------
// key: S.$String
S.Record(S.String, S.Number).key
// value: S.$Number
S.Record(S.String, S.Number).value
// ------------------------
// union members
// ------------------------
// members: readonly [S.$String, S.$Number]
S.Union(S.String, S.Number).members
// ------------------------
// tuple elements
// ------------------------
// elements: readonly [S.$String, S.$Number]
S.Tuple(S.String, S.Number).elements
Annotation Compatibility
All the API interfaces equipped with schemas and built-in combinators are compatible with the annotations
method, meaning that their type is not lost but remains the original one before annotation:
import * as S from "@effect/schema/Schema"
// const Name: S.$String
const Name = S.String.annotations({ identifier: "Name" })
As you can see, the type of Name
is still $String
and has not been lost, becoming Schema<string, string, never>
.
This doesn't happen by default with API interfaces defined in userland:
import * as S from "@effect/schema/Schema"
// API interface
interface Age extends S.Schema<number> {}
const Age: Age = S.Number.pipe(S.between(0, 100))
// const AnotherAge: S.Schema<number, number, never>
const AnotherAge = Age.annotations({ identifier: "AnotherAge" })
However, the fix is very simple; just modify the definition of the Age
API interface using the Annotable
interface exported by @effect/schema
:
import * as S from "@effect/schema/Schema"
// API interface
interface Age extends S.Annotable<Age, number> {}
const Age: Age = S.Number.pipe(S.between(0, 100))
// const AnotherAge: Age
const AnotherAge = Age.annotations({ identifier: "AnotherAge" })
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(S.Literal("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.optional(S.String, { exact: true }) })
|
| Record<A, B>
| records | S.Record(A, 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(A, B), S.Record(C, 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 * as S from "@effect/schema/Schema"
S.String // Schema<string>
S.Number // Schema<number>
S.Boolean // Schema<boolean>
S.BigIntFromSelf // Schema<BigInt>
S.SymbolFromSelf // Schema<symbol>
S.Object // Schema<object>
S.Undefined // Schema<undefined>
S.Void // Schema<void>
S.Any // Schema<any>
S.Unknown // Schema<unknown>
S.Never // 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 * as S from "@effect/schema/Schema"
S.Null // same as S.Literal(null)
S.Literal("a")
S.Literal("a", "b", "c") // union of literals
S.Literal(1)
S.Literal(2n) // BigInt literal
S.Literal(true)
We can also use pickLiteral
with a literal schema to narrow down the possible values:
import * as S from "@effect/schema/Schema"
S.Literal("a", "b", "c").pipe(S.pickLiteral("a", "b")) //same as S.Literal("a", "b")
Sometimes, we need to reuse a schema literal in other parts of our code. Let's see an example:
import * as S from "@effect/schema/Schema"
const FruitId = S.Number
// the source of truth regarding the Fruit category
const FruitCategory = S.Literal("sweet", "citrus", "tropical")
const Fruit = S.Struct({
id: FruitId,
category: FruitCategory
})
// Here, we want to reuse our FruitCategory definition to create a subtype of Fruit
const SweetAndCitrusFruit = S.Struct({
fruitId: FruitId,
category: FruitCategory.pipe(S.pickLiteral("sweet", "citrus"))
/*
By using pickLiteral from the FruitCategory, we ensure that the values selected
are those defined in the category definition above.
If we remove "sweet" from the FruitCategory definition, TypeScript will notify us.
*/
})
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 * as S from "@effect/schema/Schema"
const schema = S.Literal("a", "b")
// Accesses the literals
const literals = schema.literals // readonly ["a", "b"]
Template literals
The TemplateLiteral
constructor allows you to create a schema for a TypeScript template literal type.
import * as S from "@effect/schema/Schema"
// Schema<`a${string}`>
S.TemplateLiteral(S.Literal("a"), S.String)
// example from https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html
const EmailLocaleIDs = S.Literal("welcome_email", "email_heading")
const FooterLocaleIDs = S.Literal("footer_title", "footer_sendoff")
// Schema<"welcome_email_id" | "email_heading_id" | "footer_title_id" | "footer_sendoff_id">
S.TemplateLiteral(S.Union(EmailLocaleIDs, FooterLocaleIDs), S.Literal("_id"))
Unique Symbols
import * as S from "@effect/schema/Schema"
const mySymbol = Symbol.for("mysymbol")
// const mySymbolSchema: S.Schema<typeof mySymbol>
const mySymbolSchema = S.UniqueSymbolFromSelf(mySymbol)
Filters
In the @effect/schema/Schema
library, you can apply custom validation logic using filters.
You can define a custom validation check on any schema using the filter
function. Here's a simple example:
import * as S from "@effect/schema/Schema"
const LongString = S.String.pipe(
S.filter((s) => s.length >= 10, {
message: () => "a string at least 10 characters long"
})
)
console.log(S.decodeUnknownSync(LongString)("a"))
/*
throws:
Error: a string at least 10 characters long
*/
It's recommended to include as much metadata as possible for later introspection of the schema, such as an identifier, JSON schema representation, and a description:
import * as S from "@effect/schema/Schema"
const LongString = S.String.pipe(
S.filter((s) => s.length >= 10, {
message: () => "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"
})
)
For more complex scenarios, you can return an Option<ParseError>
type instead of a boolean. In this context, None
indicates success, and Some(issue)
rejects the input with a specific error. Here's an example:
import * as ParseResult from "@effect/schema/ParseResult"
import * as S from "@effect/schema/Schema"
import * as Option from "effect/Option"
const schema = S.Struct({ a: S.String, b: S.String }).pipe(
S.filter((o) =>
o.b === o.a
? Option.none()
: Option.some(
new ParseResult.Type(
S.Literal(o.a).ast,
o.b,
`b ("${o.b}") should be equal to a ("${o.a}")`
)
)
)
)
console.log(S.decodeUnknownSync(schema)({ a: "foo", b: "bar" }))
/*
throws:
Error: <refinement schema>
└─ Predicate refinement failure
└─ b ("bar") should be equal to a ("foo")
*/
[!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 theType
, consider using Branded types.
String Filters
import * as S from "@effect/schema/Schema"
S.String.pipe(S.maxLength(5)) // Specifies maximum length of a string
S.String.pipe(S.minLength(5)) // Specifies minimum length of a string
S.NonEmpty // Equivalent to ensuring the string has a minimum length of 1
S.String.pipe(S.length(5)) // Specifies exact length of a string
S.String.pipe(S.length({ min: 2, max: 4 })) // Specifies a range for the length of a string
S.String.pipe(S.pattern(regex)) // Matches a string against a regular expression pattern
S.String.pipe(S.startsWith(string)) // Ensures a string starts with a specific substring
S.String.pipe(S.endsWith(string)) // Ensures a string ends with a specific substring
S.String.pipe(S.includes(searchString)) // Checks if a string includes a specific substring
S.String.pipe(S.trimmed()) // Validates that a string has no leading or trailing whitespaces
S.String.pipe(S.lowercased()) // Validates that a string is entirely in lowercase
[!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 thetrim
combinator ot theTrim
schema.
Number Filters
import * as S from "@effect/schema/Schema"
S.Number.pipe(S.greaterThan(5)) // Specifies a number greater than 5
S.Number.pipe(S.greaterThanOrEqualTo(5)) // Specifies a number greater than or equal to 5
S.Number.pipe(S.lessThan(5)) // Specifies a number less than 5
S.Number.pipe(S.lessThanOrEqualTo(5)) // Specifies a number less than or equal to 5
S.Number.pipe(S.between(-2, 2)) // Specifies a number between -2 and 2, inclusive
S.Number.pipe(S.int()) // Specifies that the value must be an integer
S.Number.pipe(S.nonNaN()) // Ensures the value is not NaN
S.Number.pipe(S.finite()) // Ensures the value is finite and not Infinity or -Infinity
S.Number.pipe(S.positive()) // Specifies a positive number (> 0)
S.Number.pipe(S.nonNegative()) // Specifies a non-negative number (>= 0)
S.Number.pipe(S.negative()) // Specifies a negative number (< 0)
S.Number.pipe(S.nonPositive()) // Specifies a non-positive number (<= 0)
S.Number.pipe(S.multipleOf(5)) // Specifies a number that is evenly divisible by 5
BigInt Filters
import * as S from "@effect/schema/Schema"
S.BigInt.pipe(S.greaterThanBigInt(5n)) // Specifies a BigInt greater than 5
S.BigInt.pipe(S.greaterThanOrEqualToBigInt(5n)) // Specifies a BigInt greater than or equal to 5
S.BigInt.pipe(S.lessThanBigInt(5n)) // Specifies a BigInt less than 5
S.BigInt.pipe(S.lessThanOrEqualToBigInt(5n)) // Specifies a BigInt less than or equal to 5
S.BigInt.pipe(S.betweenBigInt(-2n, 2n)) // Specifies a BigInt between -2 and 2, inclusive
S.BigInt.pipe(S.positiveBigInt()) // Specifies a positive BigInt (> 0n)
S.BigInt.pipe(S.nonNegativeBigInt()) // Specifies a non-negative BigInt (>= 0n)
S.BigInt.pipe(S.negativeBigInt()) // Specifies a negative BigInt (< 0n)
S.BigInt.pipe(S.nonPositiveBigInt()) // Specifies a non-positive BigInt (<= 0n)
BigDecimal Filters
import * as S from "@effect/schema/Schema"
import * as BigDecimal from "effect/BigDecimal"
S.BigDecimal.pipe(S.greaterThanBigDecimal(BigDecimal.fromNumber(5))) // Specifies a BigDecimal greater than 5
S.BigDecimal.pipe(S.greaterThanOrEqualToBigDecimal(BigDecimal.fromNumber(5))) // Specifies a BigDecimal greater than or equal to 5
S.BigDecimal.pipe(S.lessThanBigDecimal(BigDecimal.fromNumber(5))) // Specifies a BigDecimal less than 5
S.BigDecimal.pipe(S.lessThanOrEqualToBigDecimal(BigDecimal.fromNumber(5))) // Specifies a BigDecimal less than or equal to 5
S.BigDecimal.pipe(
S.betweenBigDecimal(BigDecimal.fromNumber(-2), BigDecimal.fromNumber(2))
) // Specifies a BigDecimal between -2 and 2, inclusive
S.BigDecimal.pipe(S.positiveBigDecimal()) // Specifies a positive BigDecimal (> 0)
S.BigDecimal.pipe(S.nonNegativeBigDecimal()) // Specifies a non-negative BigDecimal (>= 0)
S.BigDecimal.pipe(S.negativeBigDecimal()) // Specifies a negative BigDecimal (< 0)
S.BigDecimal.pipe(S.nonPositiveBigDecimal()) // Specifies a non-positive BigDecimal (<= 0)
Duration Filters
import * as S from "@effect/schema/Schema"
S.Duration.pipe(S.greaterThanDuration("5 seconds")) // Specifies a duration greater than 5 seconds
S.Duration.pipe(S.greaterThanOrEqualToDuration("5 seconds")) // Specifies a duration greater than or equal to 5 seconds
S.Duration.pipe(S.lessThanDuration("5 seconds")) // Specifies a duration less than 5 seconds
S.Duration.pipe(S.lessThanOrEqualToDuration("5 seconds")) // Specifies a duration less than or equal to 5 seconds
S.Duration.pipe(S.betweenDuration("5 seconds", "10 seconds")) // Specifies a duration between 5 seconds and 10 seconds, inclusive
Array Filters
import * as S from "@effect/schema/Schema"
S.Array(S.Number).pipe(S.maxItems(2)) // Specifies the maximum number of items in the array
S.Array(S.Number).pipe(S.minItems(2)) // Specifies the minimum number of items in the array
S.Array(S.Number).pipe(S.itemsCount(2)) // Specifies the exact number of items in the array
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) // works fine
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 type * as B from "effect/Brand"
type UserId = string & B.Brand<"UserId">
type Username = string
const getUser = (id: UserId) => { ... }
const myUsername: Username = "gcanti"
getUser(myUsername) // error
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 * as S from "@effect/schema/Schema"
const UserId = S.String.pipe(S.brand("UserId"))
type UserId = S.Schema.Type<typeof UserId> // string & Brand<"UserId">
Note that you can use unique symbol
s as brands to ensure uniqueness across modules / packages:
import * as S from "@effect/schema/Schema"
const UserIdBrand = Symbol.for("UserId")
const UserId = S.String.pipe(S.brand(UserIdBrand))
type UserId = S.Schema.Type<typeof UserId> // string & Brand<typeof UserIdBrand>
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 * as B from "effect/Brand"
// the existing branded type
type UserId = string & B.Brand<"UserId">
const UserId = B.nominal<UserId>()
import * as S from "@effect/schema/Schema"
// Define a schema for the branded type
const UserIdSchema = S.String.pipe(S.fromBrand(UserId))
Native enums
import * as S from "@effect/schema/Schema"
enum Fruits {
Apple,
Banana
}
// S.enums<typeof Fruits>
S.Enums(Fruits)
Accessing Enum Members
Enums are exposed under an enums
property of the schema:
// Access the enum members
S.Enums(Fruits).enums // Returns all enum members
S.Enums(Fruits).enums.Apple // Access the Apple member
S.Enums(Fruits).enums.Banana // Access the Banana member
Nullables
import * as S from "@effect/schema/Schema"
// Represents a schema for a string or null value
S.NullOr(S.String)
// Represents a schema for a string, null, or undefined value
S.NullishOr(S.String)
// Represents a schema for a string or undefined value
S.UndefinedOr(S.String)
Unions
@effect/schema/Schema
includes a built-in union
combinator for composing "OR" types.
import * as S from "@effect/schema/Schema"
// Schema<string | number>
S.Union(S.String, S.Number)
Union of Literals
While the fol