Getting Started

On this page

This is a pre-release version. Expect possible breaking changes and inconsistencies until the stable release (v1.0) is achieved.

Installation

To install the beta version:

bash
npm install @effect/schema
bash
npm install @effect/schema

Additionally, make sure to install the effect package, as it's peer dependencies. Note that some package managers might not install peer dependencies by default, so you need to install them manually.

Once you have installed the library, you can import the necessary types and functions from the @effect/schema/Schema module.

Example (Namespace Import)

ts
import * as Schema from "@effect/schema/Schema"
ts
import * as Schema from "@effect/schema/Schema"

Example (Named Import)

ts
import { Schema } from "@effect/schema"
ts
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 constructor allows you to create a new Schema that outlines an object with specific properties. Each property in the object is defined by its own Schema, which specifies the data type and any validation rules.

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:

ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})

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

Once you've defined a Schema<A, I, R>, you can extract the inferred type A, which represents the data described by the schema, in two ways:

  • Using the Schema.Schema.Type utility.
  • Using the Type field defined on your schema.

For example, you can extract the inferred type of a Person object as demonstrated below:

ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.NumberFromString
})
 
// 1. Using the Schema.Type utility
type Person = Schema.Schema.Type<typeof Person>
/*
Equivalent to:
type Person = {
readonly name: string;
readonly age: number;
}
*/
 
// 2. Using the `Type` field
type Person2 = typeof Person.Type
ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.NumberFromString
})
 
// 1. Using the Schema.Type utility
type Person = Schema.Schema.Type<typeof Person>
/*
Equivalent to:
type Person = {
readonly name: string;
readonly age: number;
}
*/
 
// 2. Using the `Type` field
type Person2 = typeof Person.Type

Alternatively, you can define the Person type using the interface keyword:

ts
interface Person extends Schema.Schema.Type<typeof Person> {}
/*
Equivalent to:
interface Person {
readonly name: string;
readonly age: number;
}
*/
ts
interface Person extends Schema.Schema.Type<typeof Person> {}
/*
Equivalent to:
interface 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 (or the Encoded field defined on your schema).

ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.NumberFromString
})
 
// 1. Using the Schema.Encoded utility
type PersonEncoded = Schema.Schema.Encoded<typeof Person>
/*
type PersonEncoded = {
readonly name: string;
readonly age: string;
}
*/
 
// 2. Using the `Encoded` field
type PersonEncoded2 = typeof Person.Encoded
ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.NumberFromString
})
 
// 1. Using the Schema.Encoded utility
type PersonEncoded = Schema.Schema.Encoded<typeof Person>
/*
type PersonEncoded = {
readonly name: string;
readonly age: string;
}
*/
 
// 2. Using the `Encoded` field
type PersonEncoded2 = typeof Person.Encoded

Context

You can also extract the inferred type R that represents the context described by the schema using the Schema.Context utility:

ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.NumberFromString
})
 
// type PersonContext = never
type PersonContext = Schema.Schema.Context<typeof Person>
ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.NumberFromString
})
 
// type PersonContext = never
type PersonContext = Schema.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:

ts
import { Schema } from "@effect/schema"
 
const _Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
interface Person extends Schema.Schema.Type<typeof _Person> {}
 
// Re-declare the schema to create a schema with an opaque type
const Person: Schema.Schema<Person> = _Person
ts
import { Schema } from "@effect/schema"
 
const _Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
interface Person extends Schema.Schema.Type<typeof _Person> {}
 
// Re-declare the schema to create a schema with an opaque type
const Person: Schema.Schema<Person> = _Person

Alternatively, you can use the Class APIs (see the Class APIs section 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:

ts
import { Schema } from "@effect/schema"
 
const _Person = Schema.Struct({
name: Schema.String,
age: Schema.NumberFromString
})
 
interface Person extends Schema.Schema.Type<typeof _Person> {}
 
interface PersonEncoded extends Schema.Schema.Encoded<typeof _Person> {}
 
// Re-declare the schema to create a schema with an opaque type
const Person: Schema.Schema<Person, PersonEncoded> = _Person
ts
import { Schema } from "@effect/schema"
 
const _Person = Schema.Struct({
name: Schema.String,
age: Schema.NumberFromString
})
 
interface Person extends Schema.Schema.Type<typeof _Person> {}
 
interface PersonEncoded extends Schema.Schema.Encoded<typeof _Person> {}
 
// Re-declare the schema to create a schema with an opaque type
const Person: Schema.Schema<Person, PersonEncoded> = _Person

In this case, the field "age" is of type string in the Encoded type of the schema and is of type number in the Type type of the schema. Therefore, we need to define two interfaces (PersonEncoded and Person) and use both to redeclare our final schema Person.

Decoding From Unknown Values

When working with unknown data types in TypeScript, decoding them into a known structure can be challenging. Luckily, @effect/schema provides several functions to help with this process. Let's explore how to decode unknown values using these functions.

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

ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
// Simulate an unknown input
const input: unknown = { name: "Alice", age: 30 }
 
console.log(Schema.decodeUnknownSync(Person)(input))
// Output: { name: 'Alice', age: 30 }
 
console.log(Schema.decodeUnknownSync(Person)(null))
/*
throws:
ParseError: Expected { readonly name: string; readonly age: number }, actual null
*/
ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
// Simulate an unknown input
const input: unknown = { name: "Alice", age: 30 }
 
console.log(Schema.decodeUnknownSync(Person)(input))
// Output: { name: 'Alice', age: 30 }
 
console.log(Schema.decodeUnknownSync(Person)(null))
/*
throws:
ParseError: Expected { readonly name: string; readonly 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.

ts
import { Schema } from "@effect/schema"
import { Either } from "effect"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
const decode = Schema.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 { readonly name: string; readonly age: number }, actual null'
}
*/
}
ts
import { Schema } from "@effect/schema"
import { Either } from "effect"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
const decode = Schema.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 { readonly name: string; readonly age: number }, actual null'
}
*/
}

The decode function returns an Either<A, ParseError>, where ParseError is defined as follows:

ts
interface ParseError {
readonly _tag: "ParseError"
readonly issue: ParseIssue
}
ts
interface ParseError {
readonly _tag: "ParseError"
readonly issue: ParseIssue
}

Here, ParseIssue represents an error that might occur during the parsing process. It is wrapped in a tagged error to make it easier to catch errors using Effect.catchTag. The result Either<A, ParseError> contains the inferred data type described by the schema. A successful parse yields a Right value with the parsed data A, while a failed parse results in a Left value containing a ParseError.

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.

ts
import { Schema } from "@effect/schema"
import { Effect } from "effect"
 
const PersonId = Schema.Number
 
const Person = Schema.Struct({
id: PersonId,
name: Schema.String,
age: Schema.Number
})
 
const asyncSchema = Schema.transformOrFail(PersonId, Person, {
strict: true,
// 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 = Schema.decodeUnknownEither(asyncSchema)
 
console.log(JSON.stringify(syncParsePersonId(1), null, 2))
/*
Output:
{
"_id": "Either",
"_tag": "Left",
"left": {
"_id": "ParseError",
"message": "(number <-> { readonly id: number; readonly name: string; readonly age: number })\n└─ cannot be be resolved synchronously, this is caused by using runSync on an effect that performs async work"
}
}
*/
 
const asyncParsePersonId = Schema.decodeUnknown(asyncSchema)
 
Effect.runPromise(asyncParsePersonId(1)).then(console.log)
/*
Output:
{ id: 1, name: 'name', age: 18 }
*/
ts
import { Schema } from "@effect/schema"
import { Effect } from "effect"
 
const PersonId = Schema.Number
 
const Person = Schema.Struct({
id: PersonId,
name: Schema.String,
age: Schema.Number
})
 
const asyncSchema = Schema.transformOrFail(PersonId, Person, {
strict: true,
// 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 = Schema.decodeUnknownEither(asyncSchema)
 
console.log(JSON.stringify(syncParsePersonId(1), null, 2))
/*
Output:
{
"_id": "Either",
"_tag": "Left",
"left": {
"_id": "ParseError",
"message": "(number <-> { readonly id: number; readonly name: string; readonly age: number })\n└─ cannot be be resolved synchronously, this is caused by using runSync on an effect that performs async work"
}
}
*/
 
const asyncParsePersonId = Schema.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.

Parse Options

Excess properties

When using a Schema to parse a value, by default any properties that are not specified in the Schema will be stripped out from the output. This is because the Schema is expecting a specific shape for the parsed value, and any excess properties do not conform to that shape.

However, you can use the onExcessProperty option (default value: "ignore") to trigger a parsing error. This can be particularly useful in cases where you need to detect and handle potential errors or unexpected values.

Here's an example of how you might use onExcessProperty set to "error":

ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
console.log(
Schema.decodeUnknownSync(Person)({
name: "Bob",
age: 40,
})
)
/*
Output:
{ name: 'Bob', age: 40 }
*/
 
Schema.decodeUnknownSync(Person)(
{
name: "Bob",
age: 40,
},
{ onExcessProperty: "error" }
)
/*
throws
ParseError: { readonly name: string; readonly age: number }
└─ ["email"]
└─ is unexpected, expected: "name" | "age"
*/
ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
console.log(
Schema.decodeUnknownSync(Person)({
name: "Bob",
age: 40,
})
)
/*
Output:
{ name: 'Bob', age: 40 }
*/
 
Schema.decodeUnknownSync(Person)(
{
name: "Bob",
age: 40,
},
{ onExcessProperty: "error" }
)
/*
throws
ParseError: { readonly name: string; readonly age: number }
└─ ["email"]
└─ is unexpected, expected: "name" | "age"
*/

If you want to allow excess properties to remain, you can use onExcessProperty set to "preserve":

ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
console.log(
Schema.decodeUnknownSync(Person)(
{
name: "Bob",
age: 40,
},
{ onExcessProperty: "preserve" }
)
)
/*
{ email: '[email protected]', name: 'Bob', age: 40 }
*/
ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
console.log(
Schema.decodeUnknownSync(Person)(
{
name: "Bob",
age: 40,
},
{ onExcessProperty: "preserve" }
)
)
/*
{ email: '[email protected]', name: 'Bob', age: 40 }
*/

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:

ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
Schema.decodeUnknownSync(Person)(
{
name: "Bob",
age: "abc",
},
{ errors: "all", onExcessProperty: "error" }
)
/*
throws
ParseError: { readonly name: string; readonly age: number }
├─ ["email"]
│ └─ is unexpected, expected: "name" | "age"
└─ ["age"]
└─ Expected number, actual "abc"
*/
ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
Schema.decodeUnknownSync(Person)(
{
name: "Bob",
age: "abc",
},
{ errors: "all", onExcessProperty: "error" }
)
/*
throws
ParseError: { readonly name: string; readonly age: number }
├─ ["email"]
│ └─ is unexpected, expected: "name" | "age"
└─ ["age"]
└─ Expected number, actual "abc"
*/

The onExcessProperty and error options also affect encoding.

Managing Property Order

The propertyOrder option provides control over the order of object fields in the output. This feature is particularly useful when the sequence of keys is important for the consuming processes or when maintaining the input order enhances readability and usability.

By default, the propertyOrder option is set to "none". This means that the internal system decides the order of keys to optimize parsing speed. The order of keys in this mode should not be considered stable, and it's recommended not to rely on key ordering as it may change in future updates without notice.

Setting propertyOrder to "original" ensures that the keys are ordered as they appear in the input during the decoding/encoding process.

Example (Synchronous Decoding)

ts
import { Schema } from "@effect/schema"
 
const schema = Schema.Struct({
a: Schema.Number,
b: Schema.Literal("b"),
c: Schema.Number
})
 
// Decoding an object synchronously without specifying the property order
console.log(Schema.decodeUnknownSync(schema)({ b: "b", c: 2, a: 1 }))
// Output decided internally: { b: 'b', a: 1, c: 2 }
 
// Decoding an object synchronously while preserving the order of properties as in the input
console.log(
Schema.decodeUnknownSync(schema)(
{ b: "b", c: 2, a: 1 },
{ propertyOrder: "original" }
)
)
// Output preserving input order: { b: 'b', c: 2, a: 1 }
ts
import { Schema } from "@effect/schema"
 
const schema = Schema.Struct({
a: Schema.Number,
b: Schema.Literal("b"),
c: Schema.Number
})
 
// Decoding an object synchronously without specifying the property order
console.log(Schema.decodeUnknownSync(schema)({ b: "b", c: 2, a: 1 }))
// Output decided internally: { b: 'b', a: 1, c: 2 }
 
// Decoding an object synchronously while preserving the order of properties as in the input
console.log(
Schema.decodeUnknownSync(schema)(
{ b: "b", c: 2, a: 1 },
{ propertyOrder: "original" }
)
)
// Output preserving input order: { b: 'b', c: 2, a: 1 }

Example (Asynchronous Decoding)

ts
import { ParseResult, Schema } from "@effect/schema"
import type { Duration } from "effect"
import { Effect } from "effect"
 
// Function to simulate an asynchronous process within the schema
const effectify = (duration: Duration.DurationInput) =>
Schema.Number.pipe(
Schema.transformOrFail(Schema.Number, {
strict: true,
decode: (x) =>
Effect.sleep(duration).pipe(Effect.andThen(ParseResult.succeed(x))),
encode: ParseResult.succeed
})
)
 
// Define a structure with asynchronous behavior in each field
const schema = Schema.Struct({
a: effectify("200 millis"),
b: effectify("300 millis"),
c: effectify("100 millis")
}).annotations({ concurrency: 3 })
 
// Decoding data asynchronously without preserving order
Schema.decode(schema)({ a: 1, b: 2, c: 3 })
.pipe(Effect.runPromise)
.then(console.log)
// Output decided internally: { c: 3, a: 1, b: 2 }
 
// Decoding data asynchronously while preserving the original input order
Schema.decode(schema)({ a: 1, b: 2, c: 3 }, { propertyOrder: "original" })
.pipe(Effect.runPromise)
.then(console.log)
// Output preserving input order: { a: 1, b: 2, c: 3 }
ts
import { ParseResult, Schema } from "@effect/schema"
import type { Duration } from "effect"
import { Effect } from "effect"
 
// Function to simulate an asynchronous process within the schema
const effectify = (duration: Duration.DurationInput) =>
Schema.Number.pipe(
Schema.transformOrFail(Schema.Number, {
strict: true,
decode: (x) =>
Effect.sleep(duration).pipe(Effect.andThen(ParseResult.succeed(x))),
encode: ParseResult.succeed
})
)
 
// Define a structure with asynchronous behavior in each field
const schema = Schema.Struct({
a: effectify("200 millis"),
b: effectify("300 millis"),
c: effectify("100 millis")
}).annotations({ concurrency: 3 })
 
// Decoding data asynchronously without preserving order
Schema.decode(schema)({ a: 1, b: 2, c: 3 })
.pipe(Effect.runPromise)
.then(console.log)
// Output decided internally: { c: 3, a: 1, b: 2 }
 
// Decoding data asynchronously while preserving the original input order
Schema.decode(schema)({ a: 1, b: 2, c: 3 }, { propertyOrder: "original" })
.pipe(Effect.runPromise)
.then(console.log)
// Output preserving input order: { a: 1, b: 2, c: 3 }

Customizing Parsing Behavior at the Schema Level

You can tailor parse options for each schema using the parseOptions annotation. These options allow for specific parsing behavior at various levels of the schema hierarchy, overriding any parent settings and cascading down to nested schemas.

ts
import { Schema } from "@effect/schema"
import { Either } from "effect"
 
const schema = Schema.Struct({
a: Schema.Struct({
b: Schema.String,
c: Schema.String
}).annotations({
title: "first error only",
parseOptions: { errors: "first" } // Only the first error in this sub-schema is reported
}),
d: Schema.String
}).annotations({
title: "all errors",
parseOptions: { errors: "all" } // All errors in the main schema are reported
})
 
const result = Schema.decodeUnknownEither(schema)(
{ a: {} },
{ errors: "first" }
)
if (Either.isLeft(result)) {
console.log(result.left.message)
}
/*
all errors
├─ ["d"]
│ └─ is missing
└─ ["a"]
└─ first error only
└─ ["b"]
└─ is missing
*/
ts
import { Schema } from "@effect/schema"
import { Either } from "effect"
 
const schema = Schema.Struct({
a: Schema.Struct({
b: Schema.String,
c: Schema.String
}).annotations({
title: "first error only",
parseOptions: { errors: "first" } // Only the first error in this sub-schema is reported
}),
d: Schema.String
}).annotations({
title: "all errors",
parseOptions: { errors: "all" } // All errors in the main schema are reported
})
 
const result = Schema.decodeUnknownEither(schema)(
{ a: {} },
{ errors: "first" }
)
if (Either.isLeft(result)) {
console.log(result.left.message)
}
/*
all errors
├─ ["d"]
│ └─ is missing
└─ ["a"]
└─ first error only
└─ ["b"]
└─ is missing
*/

Detailed Output Explanation:

In this example:

  • The main schema is configured to display all errors. Hence, you will see errors related to both the d field (since it's missing) and any errors from the a subschema.
  • The subschema (a) is set to display only the first error. Although both b and c fields are missing, only the first missing field (b) is reported.

Managing Missing Properties

When using the @effect/schema library to handle data structures, it's important to understand how missing properties are processed. By default, if a property is not present in the input, it is treated as if it were present with an undefined value.

ts
import { Schema } from "@effect/schema"
 
const schema = Schema.Struct({ a: Schema.Unknown })
const input = {}
 
console.log(Schema.decodeUnknownSync(schema)(input)) // Output: { a: undefined }
ts
import { Schema } from "@effect/schema"
 
const schema = Schema.Struct({ a: Schema.Unknown })
const input = {}
 
console.log(Schema.decodeUnknownSync(schema)(input)) // Output: { a: undefined }

In this example, although the key "a" is not present in the input, it is treated as { a: undefined } by default.

If your validation logic needs to distinguish between truly missing properties and those that are explicitly undefined, you can enable the exact option:

ts
import { Schema } from "@effect/schema"
 
const schema = Schema.Struct({ a: Schema.Unknown })
const input = {}
 
console.log(Schema.decodeUnknownSync(schema)(input, { exact: true }))
/*
throws
ParseError: { readonly a: unknown }
└─ ["a"]
└─ is missing
*/
ts
import { Schema } from "@effect/schema"
 
const schema = Schema.Struct({ a: Schema.Unknown })
const input = {}
 
console.log(Schema.decodeUnknownSync(schema)(input, { exact: true }))
/*
throws
ParseError: { readonly a: unknown }
└─ ["a"]
└─ is missing
*/

For the APIs Schema.is and Schema.asserts, however, the default behavior is to treat missing properties strictly, where the default for exact is true:

ts
import type { AST } from "@effect/schema"
import { Schema } from "@effect/schema"
 
const schema = Schema.Struct({ a: Schema.Unknown })
const input = {}
 
console.log(Schema.is(schema)(input)) // Output: false
console.log(Schema.is(schema)(input, { exact: false })) // Output: true
 
const asserts: (
u: unknown,
overrideOptions?: AST.ParseOptions
) => asserts u is {
readonly a: unknown
} = Schema.asserts(schema)
 
try {
asserts(input)
console.log("asserts passed")
} catch (e: any) {
console.error("asserts failed")
console.error(e.message)
}
/*
Output:
asserts failed
{ readonly a: unknown }
└─ ["a"]
└─ is missing
*/
 
try {
asserts(input, { exact: false })
console.log("asserts passed")
} catch (e: any) {
console.error("asserts failed")
console.error(e.message)
}
// Output: asserts passed
ts
import type { AST } from "@effect/schema"
import { Schema } from "@effect/schema"
 
const schema = Schema.Struct({ a: Schema.Unknown })
const input = {}
 
console.log(Schema.is(schema)(input)) // Output: false
console.log(Schema.is(schema)(input, { exact: false })) // Output: true
 
const asserts: (
u: unknown,
overrideOptions?: AST.ParseOptions
) => asserts u is {
readonly a: unknown
} = Schema.asserts(schema)
 
try {
asserts(input)
console.log("asserts passed")
} catch (e: any) {
console.error("asserts failed")
console.error(e.message)
}
/*
Output:
asserts failed
{ readonly a: unknown }
└─ ["a"]
└─ is missing
*/
 
try {
asserts(input, { exact: false })
console.log("asserts passed")
} catch (e: any) {
console.error("asserts failed")
console.error(e.message)
}
// Output: asserts passed

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.

ts
import { Schema } from "@effect/schema"
 
// Age is a schema that can decode a string to a number and encode a number to a string
const Age = Schema.NumberFromString
 
const Person = Schema.Struct({
name: Schema.NonEmptyString,
age: Age
})
 
console.log(Schema.encodeSync(Person)({ name: "Alice", age: 30 }))
// Output: { name: 'Alice', age: '30' }
 
console.log(Schema.encodeSync(Person)({ name: "", age: 30 }))
/*
throws:
ParseError: { readonly name: NonEmpty; readonly age: NumberFromString }
└─ ["name"]
└─ NonEmpty
└─ Predicate refinement failure
└─ Expected NonEmpty (a non empty string), actual ""
*/
ts
import { Schema } from "@effect/schema"
 
// Age is a schema that can decode a string to a number and encode a number to a string
const Age = Schema.NumberFromString
 
const Person = Schema.Struct({
name: Schema.NonEmptyString,
age: Age
})
 
console.log(Schema.encodeSync(Person)({ name: "Alice", age: 30 }))
// Output: { name: 'Alice', age: '30' }
 
console.log(Schema.encodeSync(Person)({ name: "", age: 30 }))
/*
throws:
ParseError: { readonly name: NonEmpty; readonly 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".

The onExcessProperty and error options also affect encoding.

Handling Unsupported Encoding

Although it is generally recommended to define schemas that support both decoding and encoding, there are situations where encoding support might be impossible. In such cases, the Forbidden error can be used to handle unsupported encoding.

Here is an example of a transformation that never fails during decoding. It returns an Either containing either the decoded value or the original input. For encoding, it is reasonable to not support it and use Forbidden as the result.

ts
import { ParseResult, Schema } from "@effect/schema"
import { Either } from "effect"
 
// Define a schema that safely decodes to Either type
export const SafeDecode = <A, I>(self: Schema.Schema<A, I, never>) => {
const decodeUnknownEither = Schema.decodeUnknownEither(self)
return Schema.transformOrFail(
Schema.Unknown,
Schema.EitherFromSelf({
left: Schema.Unknown,
right: Schema.typeSchema(self)
}),
{
strict: true,
decode: (input) =>
ParseResult.succeed(
Either.mapLeft(decodeUnknownEither(input), () => input)
),
encode: (actual, _, ast) =>
Either.match(actual, {
onLeft: () =>
ParseResult.fail(
new ParseResult.Forbidden(ast, actual, "cannot encode a Left")
),
onRight: ParseResult.succeed
})
}
)
}
ts
import { ParseResult, Schema } from "@effect/schema"
import { Either } from "effect"
 
// Define a schema that safely decodes to Either type
export const SafeDecode = <A, I>(self: Schema.Schema<A, I, never>) => {
const decodeUnknownEither = Schema.decodeUnknownEither(self)
return Schema.transformOrFail(
Schema.Unknown,
Schema.EitherFromSelf({
left: Schema.Unknown,
right: Schema.typeSchema(self)
}),
{
strict: true,
decode: (input) =>
ParseResult.succeed(
Either.mapLeft(decodeUnknownEither(input), () => input)
),
encode: (actual, _, ast) =>
Either.match(actual, {
onLeft: () =>
ParseResult.fail(
new ParseResult.Forbidden(ast, actual, "cannot encode a Left")
),
onRight: ParseResult.succeed
})
}
)
}

Explanation

  • Decoding: The SafeDecode function ensures that decoding never fails. It wraps the decoded value in an Either, where a successful decoding results in a Right and a failed decoding results in a Left containing the original input.
  • Encoding: The encoding process uses the Forbidden error to indicate that encoding a Left value is not supported. Only Right values are successfully encoded.

Naming Conventions

The naming conventions in @effect/schema are designed to be straightforward and logical, focusing primarily on compatibility with JSON serialization. This approach simplifies the understanding and use of schemas, especially for developers who are integrating web technologies where JSON is a standard data interchange format.

Overview of Naming Strategies

JSON-Compatible Types

Schemas that naturally serialize to JSON-compatible formats are named directly after their data types.

For instance:

  • Schema.Date: serializes JavaScript Date objects to ISO-formatted strings, a typical method for representing dates in JSON.
  • Schema.Number: used directly as it maps precisely to the JSON number type, requiring no special transformation to remain JSON-compatible.

Non-JSON-Compatible Types

When dealing with types that do not have a direct representation in JSON, the naming strategy incorporates additional details to indicate the necessary transformation. This helps in setting clear expectations about the schema's behavior:

For instance:

  • Schema.DateFromSelf: indicates that the schema handles Date objects, which are not natively JSON-serializable.
  • Schema.NumberFromString: this naming suggests that the schema processes numbers that are initially represented as strings, emphasizing the transformation from string to number when decoding.

Practical Application

The primary goal of these schemas is to ensure that domain objects can be easily serialized ("encoded") and deserialized ("decoded") for transmission over network connections, thus facilitating their transfer between different parts of an application or across different applications.

Here is an example demonstrating how straightforward naming conventions can be applied:

ts
import { Schema } from "@effect/schema"
 
const schema = Schema.Struct({
sym: Schema.Symbol,
optional: Schema.Option(Schema.Date),
chunk: Schema.Chunk(Schema.BigInt),
createdAt: Schema.Date,
updatedAt: Schema.Date
})
 
// This approach is preferred over more complex naming conventions like:
/*
const schema = Schema.Struct({
sym: Schema.SymbolFromString,
optional: Schema.OptionFromJson(Schema.DateFromString),
chunk: Schema.ChunkFromJson(Schema.BigIntFromString),
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString
})
*/
ts
import { Schema } from "@effect/schema"
 
const schema = Schema.Struct({
sym: Schema.Symbol,
optional: Schema.Option(Schema.Date),
chunk: Schema.Chunk(Schema.BigInt),
createdAt: Schema.Date,
updatedAt: Schema.Date
})
 
// This approach is preferred over more complex naming conventions like:
/*
const schema = Schema.Struct({
sym: Schema.SymbolFromString,
optional: Schema.OptionFromJson(Schema.DateFromString),
chunk: Schema.ChunkFromJson(Schema.BigIntFromString),
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString
})
*/

Rationale

While JSON's ubiquity justifies its primary consideration in naming, the conventions also accommodate serialization for other types of transport. For instance, converting a Date to a string is a universally useful method for various communication protocols, not just JSON. Thus, the selected naming conventions serve as sensible defaults that prioritize clarity and ease of use, facilitating the serialization and deserialization processes across diverse technological environments.

Type Guards

The Schema.is function provided by the @effect/schema/Schema module represents a way of verifying that a value conforms to a given schema. It functions as a type guard, taking a value of type unknown and determining if it matches the structure and type constraints defined in the schema.

Here's how the Schema.is function works

  1. Schema Definition: Define a schema to describe the structure and constraints of the data type you expect. For instance, Schema<A, I, R> where A is the desired type.

  2. Type Guard Creation: Convert the schema into a user-defined type guard (u: unknown) => u is A. This allows you to assert at runtime whether a value meets the specified schema.

The type I, typically used in schema transformations, does not influence the generation of the type guard. The primary focus is on ensuring that the input conforms to the desired type A.

Example Usage:

ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
/*
const isPerson: (a: unknown, options?: number | ParseOptions) => a is {
readonly name: string;
readonly age: number;
}
*/
const isPerson = Schema.is(Person)
 
console.log(isPerson({ name: "Alice", age: 30 })) // true
console.log(isPerson(null)) // false
console.log(isPerson({})) // false
ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
/*
const isPerson: (a: unknown, options?: number | ParseOptions) => a is {
readonly name: string;
readonly age: number;
}
*/
const isPerson = Schema.is(Person)
 
console.log(isPerson({ name: "Alice", age: 30 })) // true
console.log(isPerson(null)) // false
console.log(isPerson({})) // false

Assertions

While type guards verify and inform about type conformity, the Schema.asserts function takes it a step further by asserting that an input matches the schema A type (from Schema<A, I, R>). If the input does not match, it throws a detailed error.

Example Usage:

ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
// equivalent to: (input: unknown, options?: ParseOptions) => asserts input is { readonly name: string; readonly age: number; }
const assertsPerson: Schema.Schema.ToAsserts<typeof Person> =
Schema.asserts(Person)
 
try {
assertsPerson({ name: "Alice", age: "30" })
} catch (e) {
console.error("The input does not match the schema:")
console.error(e)
}
/*
The input does not match the schema:
{
_id: 'ParseError',
message: '{ readonly name: string; readonly age: number }\n' +
'└─ ["age"]\n' +
' └─ Expected number, actual "30"'
}
*/
 
// this will not throw an error
assertsPerson({ name: "Alice", age: 30 })
ts
import { Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
// equivalent to: (input: unknown, options?: ParseOptions) => asserts input is { readonly name: string; readonly age: number; }
const assertsPerson: Schema.Schema.ToAsserts<typeof Person> =
Schema.asserts(Person)
 
try {
assertsPerson({ name: "Alice", age: "30" })
} catch (e) {
console.error("The input does not match the schema:")
console.error(e)
}
/*
The input does not match the schema:
{
_id: 'ParseError',
message: '{ readonly name: string; readonly age: number }\n' +
'└─ ["age"]\n' +
' └─ Expected number, actual "30"'
}
*/
 
// this will not throw an error
assertsPerson({ name: "Alice", age: 30 })