npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@effect/schema

v0.66.14

Published

Modeling the schema of data structures as first-class values

Downloads

1,262,917

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 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:

  1. 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.

  2. 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:

  1. 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.

  2. 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.

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 your tsconfig.json file
  • The exactOptionalPropertyTypes flag enabled in your tsconfig.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 return readonly types. For instance, in the Person 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 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 * 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 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 * 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 and error 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 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"

// 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 and error 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 the identifier 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 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 * 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 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<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 the Type, 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 the trim combinator ot the Trim 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 symbols 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