Modeling the schema of data structures as first-class values
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<I, A>
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:
- Parsing from an
unknown
value to an output type A
. - Decoding from an input type
I
to an output type A
. - Encoding from an output type
A
back to an input type I
. - Ensuring that a value conforms to a specified
Schema
. - Generating fast-check arbitraries.
- Facilitating pretty printing.
If you're eager to learn how to define your first schema, jump straight to the Basic usage section!
Understanding Parsing, Decoding, and Encoding
We'll break down these concepts using an example with a Schema<string, Date>
. 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.
Parsing
Parsing involves two key steps:
-
Checking: Initially, we verify that the input data (which is of the unknown
type) matches the expected structure. In our specific case, this means ensuring that the input is indeed a string
.
-
Decoding: Following the successful check, we proceed to convert the string
into a Date
. This process completes the parsing operation, where the data is both validated and transformed.
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
}
}
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 Schema from "@effect/schema/Schema";
const schema = Schema.struct({
myfield: Schema.optional(Schema.string.pipe(Schema.nonEmpty()))
});
Schema.decodeSync(schema)({ myfield: undefined });
Here, notice that the type of myfield
is strict (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 Schema from "@effect/schema/Schema";
const schema = Schema.struct({
myfield: Schema.optional(Schema.string.pipe(Schema.nonEmpty()))
});
Schema.decodeSync(schema)({ myfield: undefined });
In this case, the type of myfield
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
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.
import * as S from "@effect/schema/Schema";
Defining a schema
To define a Schema
, you can use the provided struct
function to define a new Schema
that describes an object with a fixed set of properties. Each property of the object is described by a Schema
, which specifies the data type and validation rules for that property.
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
});
You can also use the union
function to define a Schema
that describes a value that can be one of a fixed set of types. For example, the following Schema
describes a value that can be either a string
or a number
:
const StringOrNumber = S.union(S.string, S.number);
In addition to the provided struct
and union
functions, @effect/schema/Schema
also provides a number of other functions for defining Schema
s, including functions for defining arrays, tuples, and records.
Once you have defined a Schema
, you can use the To
type to extract the inferred type of the data described by the Schema
.
For example, given the Person
Schema
defined above, you can extract the inferred type of a Person
object as follows:
interface Person extends S.Schema.To<typeof Person> {}
Parsing
To use the Schema
defined above to parse a value from unknown
, you can use the parse
function from the @effect/schema/Schema
module:
import * as S from "@effect/schema/Schema";
import * as E from "effect/Either";
const Person = S.struct({
name: S.string,
age: S.number
});
const parsePerson = S.parseEither(Person);
const input: unknown = { name: "Alice", age: 30 };
const result1 = parsePerson(input);
if (E.isRight(result1)) {
console.log(result1.right);
}
const result2 = parsePerson(null);
if (E.isLeft(result2)) {
console.log(result2.left);
}
The parsePerson
function returns a value of type ParseResult<A>
, which is a type alias for Either<NonEmptyReadonlyArray<ParseErrors>, A>
, where NonEmptyReadonlyArray<ParseErrors>
represents a list of errors that occurred during the parsing process and A
is the inferred type of the data described by the Schema
. A successful parse will result in a Right
, containing the parsed data. A Right
value indicates that the parse was successful and no errors occurred. In the case of a failed parse, the result will be a Left
value containing a list of ParseError
s.
The parseSync
function is used to parse a value and throw an error if the parsing fails.
It is useful when you want to ensure that the value being parsed is in the correct format, and want to throw an error if it is not.
try {
const person = S.parseSync(Person)({});
console.log(person);
} catch (e) {
console.error("Parsing failed:");
console.error(e);
}
Excess properties
When using a Schema
to parse a value, 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
:
import * as S from "@effect/schema/Schema";
const Person = S.struct({
name: S.string,
age: S.number
});
console.log(
S.parseSync(Person)({
name: "Bob",
age: 40,
email: "bob@example.com"
})
);
S.parseSync(Person)(
{
name: "Bob",
age: 40,
email: "bob@example.com"
},
{ onExcessProperty: "error" }
);
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.parseSync(Person)(
{
name: "Bob",
age: "abc",
email: "bob@example.com"
},
{ errors: "all", onExcessProperty: "error" }
);
Encoding
To use the Schema
defined above to encode a value to unknown
, you can use the encode
function:
import * as S from "@effect/schema/Schema";
import * as E from "effect/Either";
const Age = S.NumberFromString;
const Person = S.struct({
name: S.string,
age: Age
});
const encoded = S.encodeEither(Person)({ name: "Alice", age: 30 });
if (E.isRight(encoded)) {
console.log(encoded.right);
}
Note that during encoding, the number value 30
was converted to a string "30"
.
Formatting Errors
When you're working with Effect Schema and encounter errors during parsing, 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 way to format errors. It arranges errors in a tree structure, making it easy to see the hierarchy of issues.
Here's an example of how it works:
import * as S from "@effect/schema/Schema";
import { formatErrors } from "@effect/schema/TreeFormatter";
import * as E from "effect/Either";
const Person = S.struct({
name: S.string,
age: S.number
});
const result = S.parseEither(Person)({});
if (E.isLeft(result)) {
console.error("Parsing failed:");
console.error(formatErrors(result.left.errors));
}
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
:
interface Issue {
readonly _tag: ParseErrors["_tag"];
readonly path: ReadonlyArray<PropertyKey>;
readonly message: string;
}
Here's an example of how it works:
import * as S from "@effect/schema/Schema";
import { formatErrors } from "@effect/schema/ArrayFormatter";
import * as E from "effect/Either";
const Person = S.struct({
name: S.string,
age: S.number
});
const result = S.parseEither(Person)(
{ name: 1, foo: 2 },
{ errors: "all", onExcessProperty: "error" }
);
if (E.isLeft(result)) {
console.error("Parsing failed:");
console.error(formatErrors(result.left.errors));
}
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 = S.is(Person);
console.log(isPerson({ name: "Alice", age: 30 }));
console.log(isPerson(null));
console.log(isPerson({}));
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: 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);
}
assertsPerson({ name: "Alice", age: 30 });
The arbitrary
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 { pipe } from "effect/Function";
import * as S from "@effect/schema/Schema";
import * as A from "@effect/schema/Arbitrary";
import * as fc from "fast-check";
const Person = S.struct({
name: S.string,
age: S.string.pipe(S.numberFromString, S.int())
});
const PersonArbitraryTo = A.to(Person)(fc);
console.log(fc.sample(PersonArbitraryTo, 2));
const PersonArbitraryFrom = A.from(Person)(fc);
console.log(fc.sample(PersonArbitraryFrom, 2));
Pretty print
The pretty
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 pretty
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 S from "@effect/schema/Schema";
import * as P from "@effect/schema/Pretty";
const Person = S.struct({
name: S.string,
age: S.number
});
const PersonPretty = P.to(Person);
console.log(PersonPretty({ name: "Alice", age: 30 }));
Basic usage
Primitives
import * as S from "@effect/schema/Schema";
S.string;
S.number;
S.bigint;
S.boolean;
S.symbol;
S.object;
S.undefined;
S.void;
S.any;
S.unknown;
S.never;
S.json;
S.UUID;
S.ULID;
Literals
S.null;
S.literal("a");
S.literal("a", "b", "c");
S.literal(1);
S.literal(2n);
S.literal(true);
Template literals
The templateLiteral
combinator allows you to create a schema for a TypeScript template literal type.
S.templateLiteral(S.literal("a"), S.string);
const EmailLocaleIDs = S.literal("welcome_email", "email_heading");
const FooterLocaleIDs = S.literal("footer_title", "footer_sendoff");
S.templateLiteral(S.union(EmailLocaleIDs, FooterLocaleIDs), S.literal("_id"));
Filters
Note. 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.
String filters
S.string.pipe(S.maxLength(5));
S.string.pipe(S.minLength(5));
S.string.pipe(nonEmpty());
S.string.pipe(S.length(5));
S.string.pipe(S.pattern(regex));
S.string.pipe(S.startsWith(string));
S.string.pipe(S.endsWith(string));
S.string.pipe(S.includes(searchString));
S.string.pipe(S.trimmed());
S.string.pipe(S.lowercased());
Note: The trimmed
combinator does not make any transformations, it only validates. If what you were looking for was a combinator to trim strings, then check out the trim
combinator ot the Trim
schema.
Number filters
S.number.pipe(S.greaterThan(5));
S.number.pipe(S.greaterThanOrEqualTo(5));
S.number.pipe(S.lessThan(5));
S.number.pipe(S.lessThanOrEqualTo(5));
S.number.pipe(S.between(-2, 2));
S.number.pipe(S.int());
S.number.pipe(S.nonNaN());
S.number.pipe(S.finite());
S.number.pipe(S.positive());
S.number.pipe(S.nonNegative());
S.number.pipe(S.negative());
S.number.pipe(S.nonPositive());
S.number.pipe(S.multipleOf(5));
Bigint filters
import * as S from "@effect/schema/Schema";
S.bigint.pipe(S.greaterThanBigint(5n));
S.bigint.pipe(S.greaterThanOrEqualToBigint(5n));
S.bigint.pipe(S.lessThanBigint(5n));
S.bigint.pipe(S.lessThanOrEqualToBigint(5n));
S.bigint.pipe(S.betweenBigint(-2n, 2n));
S.bigint.pipe(S.positiveBigint());
S.bigint.pipe(S.nonNegativeBigint());
S.bigint.pipe(S.negativeBigint());
S.bigint.pipe(S.nonPositiveBigint());
Array filters
import * as S from "@effect/schema/Schema";
S.array(S.number).pipe(S.maxItems(2));
S.array(S.number).pipe(S.minItems(2));
S.array(S.number).pipe(S.itemsCount(2));
Branded types
TypeScript's type system is structural, which means that any two types that are structurally equivalent are considered the same. This can cause issues when types that are semantically different are treated as if they were the same.
type UserId = string
type Username = string
const getUser = (id: UserId) => { ... }
const myUsername: Username = "gcanti"
getUser(myUsername)
In the above example, UserId
and Username
are both aliases for the same type, string
. This means that the getUser
function can mistakenly accept a Username
as a valid UserId
, causing bugs and errors.
To avoid these kinds of issues, the @effect
ecosystem provides a way to create custom types with a unique identifier attached to them. These are known as "branded types".
import 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)
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 { pipe } from "effect/Function";
import * as S from "@effect/schema/Schema";
const UserId = S.string.pipe(S.brand("UserId"));
type UserId = S.Schema.To<typeof UserId>;
Note that you can use unique symbol
s as brands to ensure uniqueness across modules / packages:
import { pipe } from "effect/Function";
import * as S from "@effect/schema/Schema";
const UserIdBrand = Symbol.for("UserId");
const UserId = S.string.pipe(S.brand(UserIdBrand));
type UserId = S.Schema.To<typeof UserId>;
Reusing an existing branded type
If you have already defined a branded type using the effect/Brand
module, you can reuse it to define a schema using the fromBrand
combinator exported by the @effect/schema/Schema
module. Here's an example:
import * as B from "effect/Brand";
type UserId = string & B.Brand<"UserId">;
const UserId = B.nominal<UserId>();
import { pipe } from "effect/Function";
import * as S from "@effect/schema/Schema";
const UserIdSchema = S.string.pipe(S.fromBrand(UserId));
Native enums
enum Fruits {
Apple,
Banana
}
S.enums(Fruits);
Nullables
S.nullable(S.string);
Unions
@effect/schema/Schema
includes a built-in union
combinator for composing "OR" types.
S.union(S.string, S.number);
Union of literals
While the following is perfectly acceptable:
const schema = S.union(S.literal("a"), S.literal("b"), S.literal("c"));
It is possible to use literal
and pass multiple literals, which is less cumbersome:
const schema = S.literal("a", "b", "c");
Under the hood, they are the same, as literal(...literals)
will be converted into a union.
Discriminated unions
TypeScript reference: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions
Discriminated unions in TypeScript are a way of modeling complex data structures that may take on different forms based on a specific set of conditions or properties. They allow you to define a type that represents multiple related shapes, where each shape is uniquely identified by a shared discriminant property.
In a discriminated union, each variant of the union has a common property, called the discriminant. The discriminant is a literal type, which means it can only have a finite set of possible values. Based on the value of the discriminant property, TypeScript can infer which variant of the union is currently in use.
Here is an example of a discriminated union in TypeScript:
type Circle = {
readonly kind: "circle";
readonly radius: number;
};
type Square = {
readonly kind: "square";
readonly sideLength: number;
};
type Shape = Circle | Square;
This code defines a discriminated union using the @effect/schema
library:
import * as S from "@effect/schema/Schema";
const Circle = S.struct({
kind: S.literal("circle"),
radius: S.number
});
const Square = S.struct({
kind: S.literal("square"),
sideLength: S.number
});
const Shape = S.union(Circle, Square);
The literal
combinator is used to define the discriminant property with a specific string literal value.
Two structs are defined for Circle
and Square
, each with their own properties. These structs represent the variants of the union.
Finally, the union
combinator is used to create a schema for the discriminated union Shape
, which is a union of Circle
and Square
.
How to transform a simple union into a discriminated union
If you're working on a TypeScript project and you've defined a simple union to represent a particular input, you may find yourself in a situation where you're not entirely happy with how it's set up. For example, let's say you've defined a Shape
union as a combination of Circle
and Square
without any special property:
import * as S from "@effect/schema/Schema";
const Circle = S.struct({
radius: S.number
});
const Square = S.struct({
sideLength: S.number
});
const Shape = S.union(Circle, Square);
To make your code more manageable, you may want to transform the simple union into a discriminated union. This way, TypeScript will be able to automatically determine which member of the union you're working with based on the value of a specific property.
To achieve this, you can add a special property to each member of the union, which will allow TypeScript to know which type it's dealing with at runtime. Here's how you can transform the Shape
schema into another schema that represents a discriminated union:
import * as S from "@effect/schema/Schema";
import { pipe } from "effect/Function";
const Circle = S.struct({
radius: S.number
});
const Square = S.struct({
sideLength: S.number
});
const DiscriminatedShape = S.union(
Circle.pipe(
S.transform(
Circle.pipe(S.extend(S.struct({ kind: S.literal("circle") }))),
(circle) => ({ ...circle, kind: "circle" as const }),
({ kind: _kind, ...rest }) => rest
)
),
Square.pipe(
S.transform(
Square.pipe(S.extend(S.struct({ kind: S.literal("square") }))),
(square) => ({ ...square, kind: "square" as const }),
({ kind: _kind, ...rest }) => rest
)
)
);
expect(S.parseSync(DiscriminatedShape)({ radius: 10 })).toEqual({
kind: "circle",
radius: 10
});
expect(S.parseSync(DiscriminatedShape)({ sideLength: 10 })).toEqual({
kind: "square",
sideLength: 10
});
In this example, we use the extend
function to add a "kind" property with a literal value to each member of the union. Then we use transform
to add the discriminant property and remove it afterwards. Finally, we use union
to combine the transformed schemas into a discriminated union.
However, when we use the schema to encode a value, we want the output to match the original input shape. Therefore, we must remove the discriminant property we added earlier from the encoded value to match the original shape of the input.
The previous solution works perfectly and shows how we can add and remove properties to our schema at will, making it easier to consume the result within our domain model. However, it requires a lot of boilerplate. Fortunately, there is an API called attachPropertySignature
designed specifically for this use case, which allows us to achieve the same result with much less effort:
const Circle = S.struct({ radius: S.number });
const Square = S.struct({ sideLength: S.number });
const DiscriminatedShape = S.union(
Circle.pipe(S.attachPropertySignature("kind", "circle")),
Square.pipe(S.attachPropertySignature("kind", "square"))
);
expect(S.parseSync(DiscriminatedShape)({ radius: 10 })).toEqual({
kind: "circle",
radius: 10
});
expect(
S.encodeSync(DiscriminatedShape)({
kind: "circle",
radius: 10
})
).toEqual({ radius: 10 });
Tuples
S.tuple(S.string, S.number);
Append a required element
S.tuple(S.string, S.number).pipe(S.element(S.boolean));
Append an optional element
S.tuple(S.string, S.number).pipe(S.optionalElement(S.boolean));
Append a rest element
S.tuple(S.string, S.number).pipe(S.rest(S.boolean));
Arrays
S.array(S.number);
Non empty arrays
S.nonEmptyArray(S.number);
Structs
S.struct({ a: S.string, b: S.number });
Optional fields
S.struct({ a: S.string, b: S.number, c: S.optional(S.boolean) });
Note. The optional
constructor only exists to be used in combination with the struct
API to signal an optional field and does not have a broader meaning. This means that it is only allowed to use it as an outer wrapper of a Schema
and it cannot be followed by other combinators, for example this type of operation is prohibited:
S.struct({
c: S.boolean.pipe(S.optional, S.nullable)
});
and it must be rewritten like this:
S.struct({
c: S.boolean.pipe(S.nullable, S.optional)
});
Default values
Optional fields can be configured to accept a default value, making the field optional in input and required in output:
const schema = S.struct({ a: S.optional(S.number).withDefault(() => 0) });
const parse = S.parseSync(schema);
parse({});
parse({ a: 1 });
const encode = S.encodeSync(schema);
encode({ a: 0 });
encode({ a: 1 });
Optional fields as Option
s
Optional fields can be configured to transform a value of type A
into Option<A>
, making the field optional in input and required in output:
import * as O from "effect/Option"
const schema = S.struct({ a. S.optional(S.number).toOption() });
const parse = S.parseSync(schema)
parse({})
parse({ a: 1 })
const encode = S.encodeSync(schema)
encode({ a: O.none() })
encode({ a: O.some(1) })
Classes
When working with schemas, you have a choice beyond the S.struct
constructor. You can leverage the power of classes through the Class
utility, which comes with its own set of advantages tailored to common use cases.
The Benefits of Using Classes
Classes offer several features that simplify the schema creation process:
- All-in-One Definition: With classes, you can define both a schema and an opaque type simultaneously.
- Shared Functionality: You can incorporate shared functionality using class methods or getters.
- Value Equality and Hashing: Utilize the built-in capability for checking value equality and applying hashing (thanks to
Class
implementing Data.Case
).
Let's dive into an illustrative example to better understand how classes work:
import * as S from "@effect/schema/Schema";
class Person extends S.Class<Person>()({
id: S.number,
name: S.string.pipe(S.nonEmpty())
}) {}
Validation and Instantiation
The class constructor serves as a validation and instantiation tool. It ensures that the provided properties meet the schema requirements:
const tim = new Person({ id: 1, name: "Tim" });
Keep in mind that it throws an error for invalid properties:
new Person({ id: 1, name: "" });
Custom Getters and Methods
For more flexibility, you can also introduce custom getters and methods:
import * as S from "@effect/schema/Schema";
class Person extends S.Class<Person>()({
id: S.number,
name: S.string.pipe(S.nonEmpty())
}) {
get upperName() {
return this.name.toUpperCase();
}
}
const john = new Person({ id: 1, name: "John" });
john.upperName;
Accessing Related Schemas
The class constructor itself is a Schema, and can be assigned/provided anywhere a Schema is expected. There is also a .struct
property, which can be used when the class prototype is not required.
S.lazy(() => Person);
Person.struct;
Extending existing Classes
In situations where you need to augment your existing class with more fields, the built-in extend
utility comes in handy:
class PersonWithAge extends Person.extend<PersonWithAge>()({
age: S.number
}) {
get isAdult() {
return this.age >= 18;
}
}
Transforms
You have the option to enhance a class with (effectful) transforms. This becomes valuable when you want to enrich or validate an entity sourced from a data store.
import * as Effect from "effect/Effect";
import * as S from "@effect/schema/Schema";
import * as O from "effect/Option";
import * as PR from "@effect/schema/ParseResult";
class Person extends S.Class({
id: S.number,
name: S.string
})
function fetchThing(id: number): Effect.Effect<never, Error, string> { ... }
class PersonWithTransform extends Person.transform<PersonWithTransform>()(
{
thing: S.optional(S.string).toOption(),
},
(input) =>
Effect.mapBoth(fetchThing(input.id), {
onFailure: (e) => PR.parseError([PR.type(S.string, input, e.message)]),
onSuccess: (thing) => ({ ...input, thing: O.some(thing) })
}),
PR.success
) {}
class PersonWithTransformFrom extends Person.transformFrom<PersonWithTransformFrom>()(
{
thing: S.optional(S.string).toOption(),
},
(input) =>
Effect.mapBoth(fetchThing(input.id), {
onFailure: (e) => PR.parseError([PR.type(S.string, input, e.message)]),
onSuccess: (thing) => ({ ...input, thing })
}),
PR.success
) {}
Pick
S.struct({ a: S.string, b: S.number }).pipe(S.pick("a"));
Omit
S.struct({ a: S.string, b: S.number }).pipe(S.omit("a"));
Partial
S.partial(S.struct({ a: S.string, b: S.number }));
Required
S.required(S.struct({ a: S.optional(S.string), b: S.optional(S.number) }));
Records
String keys
S.record(S.string, S.string);
S.record(S.union(S.literal("a"), S.literal("b")), S.string);
Keys refinements
S.record(S.string.pipe(S.minLength(2)), S.string);
Symbol keys
S.record(S.symbol, S.string);
Template literal keys
S.record(S.templateLiteral(S.literal("a"), S.string), S.string);
Extend
The extend
combinator allows you to add additional fields or index signatures to an existing Schema
.
S.struct({ a: S.string, b: S.string }).pipe(
S.extend(S.struct({ c: S.string })),
S.extend(S.record(S.string, S.string))
);
Compose
Combining and reusing schemas is a common requirement, the compose
combinator allows you to do just that. It enables you to combine two schemas, Schema<A, B>
and Schema<B, C>
, into a single schema Schema<A, C>
:
const schema1 = S.split(S.string, ",");
const schema2 = S.array(S.NumberFromString);
const composedSchema = S.compose(schema1, schema2);
In this example, we have two schemas, schema1
and schema2
. The first schema, schema1
, takes a string and splits it into an array using a comma as the delimiter. The second schema, schema2
, transforms an array of strings into an array of numbers.
Now, by using the compose
combinator, we can create a new schema, composedSchema
, that combines the functionality of both schema1
and schema2
. This allows us to parse a string and directly obtain an array of numbers as a result.
InstanceOf
In the following section, we demonstrate how to use the instanceOf
combinator to create a Schema
for a class instance.
class Test {
constructor(readonly name: string) {}
}
S.instanceOf(Test);
Recursive types
The lazy
combinator is useful when you need to define a Schema
that depends on itself, like in the case of recursive data structures. In this example, the Category
schema depends on itself because it has a field subcategories
that is an array of Category
objects.
interface Category {
readonly name: string;
readonly subcategories: ReadonlyArray<Category>;
}
const Category: S.Schema<Category> = S.lazy(() =>
S.struct({
name: S.string,
subcategories: S.array(Category)
})
);
Here's an example of two mutually recursive schemas, Expression
and Operation
, that represent a simple arithmetic expression tree.
interface Expression {
readonly type: "expression";
readonly value: number | Operation;
}
interface Operation {
readonly type: "operation";
readonly operator: "+" | "-";
readonly left: Expression;
readonly right: Expression;
}
const Expression: S.Schema<Expression> = S.lazy(() =>
S.struct({
type: S.literal("expression"),
value: S.union(S.number, Operation)
})
);
const Operation: S.Schema<Operation> = S.lazy(() =>
S.struct({
type: S.literal("operation"),
operator: S.union(S.literal("+"), S.literal("-")),
left: Expression,
right: Expression
})
);
Transformations
In some cases, we may need to transform the output of a schema to a different type. For instance, we may want to parse a string into a number, or we may want to transform a date string into a Date
object.
To perform these kinds of transformations, the @effect/schema
library provides the transform
combinator.
transform
<A, B, C, D>(from: Schema<A, B>, to: Schema<C, D>, decode: (b: B) => unknown, encode: (c: C) => unknown): Schema<A, D>
flowchart TD
schema1["from: Schema<A, B>"]
schema2["to: Schema<C, D>"]
schema1--decode: B -> C-->schema2
schema2--encode: C -> B-->schema1
The transform
combinator takes a source schema, a target schema, a transformation function from the source type to the target type, and a reverse transformation function from the target type back to the source type. It returns a new schema that applies the transformation function to the output of the original schema before returning it. If the original schema fails to parse a value, the transformed schema will also fail.
import * as S from "@effect/schema/Schema";
export const transformedSchema: S.Schema<string, readonly [string]> =
S.transform(
S.string,
S.tuple(S.string),
(s) => [s] as const,
([s]) => s
);
In the example above, we defined a schema for the string
type and a schema for the tuple type [string]
. We also defined the functions decode
and encode
that convert a string
into a tuple and a tuple into a string
, respectively. Then, we used the transform
combinator to convert the string schema into a schema for the tuple type [string]
. The resulting schema can be used to parse values of type string
into values of type [string]
.
transformOrFail
The transformOrFail
combinator works in a similar way, but allows the transformation function to return a ParseResult
object, which can either be a success or a failure.
import * as ParseResult from "@effect/schema/ParseResult";
import * as S from "@effect/schema/Schema";
export const transformedSchema: S.Schema<string, boolean> = S.transformOrFail(
S.string,
S.boolean,
(s) =>
s === "true"
? ParseResult.success(true)
: s === "false"
? ParseResult.success(false)
: ParseResult.failure(
ParseResult.type(S.literal("true", "false").ast, s)
),
(b) => ParseResult.success(String(b))
);
The transformation may also be async:
import * as S from "@effect/schema/Schema";
import * as ParseResult from "@effect/schema/ParseResult";
import * as Effect from "effect/Effect";
import * as TreeFormatter from "@effect/schema/TreeFormatter";
const api = (url: string) =>
Effect.tryPromise({
try: () =>
fetch(url).then((res) => {
if (res.ok) {
return res.json() as Promise<unknown>;
}
throw new Error(String(res.status));
}),
catch: (e) => new Error(String(e))
});
const PeopleId = S.string.pipe(S.brand("PeopleId"));
const PeopleIdFromString = S.transformOrFail(
S.string,
PeopleId,
(s) =>
Effect.mapBoth(api(`https://swapi.dev/api/people/${s}`), {
onFailure: (e) =>
ParseResult.parseError([ParseResult.type(PeopleId.ast, s, e.message)]),
onSuccess: () => s
}),
ParseResult.success
);
const parse = (id: string) =>
Effect.mapError(S.parse(PeopleIdFromString)(id), (e) =>
TreeFormatter.formatErrors(e.errors)
);
Effect.runPromiseExit(parse("1")).then(console.log);
Effect.runPromiseExit(parse("fail")).then(console.log);
String transformations
split
The split
combinator allows splitting a string into an array of strings.
import * as S from "@effect/schema/Schema";
const schema = S.string.pipe(S.split(","));
const parse = S.parseSync(schema);
parse("");
parse(",");
parse("a,");
parse("a,b");
Trim
The Trim
schema allows removing whitespaces from the beginning and end of a string.
import * as S from "@effect/schema/Schema";
const schema = S.Trim;
const parse = S.parseSync(schema);
parse("a");
parse(" a");
parse("a ");
parse(" a ");
Note. If you were looking for a combinator to check if a string is trimmed, check out the trimmed
combinator.
Lowercase
The Lowercase
schema converts a string to lowercase.
import * as S from "@effect/schema/Schema";
const schema = S.Lowercase;
const parse = S.parseSync(schema);
parse("A");
parse(" AB");
parse("Ab ");
parse(" ABc ");
Note. If you were looking for a combinator to check if a string is lowercased, check out the lowercased
combinator.
ParseJson
The ParseJson
schema offers a method to convert JSON strings into the unknown
type using the underlying functionality of JSON.parse
. It also employs JSON.stringify
for encoding.
import * as S from "@effect/schema/Schema";
const schema = S.ParseJson;
const parse = S.parseSync(schema);
parse("{}");
parse(`{"a":"b"}`);
parse("");
You can also compose the ParseJson
schema with other schemas to refine the parsing result:
import * as S from "@effect/schema/Schema";
const schema = S.ParseJson.pipe(S.compose(S.struct({ a: S.number })));
In this example, we've composed the ParseJson
schema with a struct schema to ensure that the result will have a specific shape, including an object with a numeric property "a".
Number transformations
NumberFromString
Transforms a string
into a number
by parsing the string using parseFloat
.
The following special string values are supported: "NaN", "Infinity", "-Infinity".
import * as S from "@effect/schema/Schema";
const schema = S.NumberFromString;
const parse = S.parseSync(schema);
parse("1");
parse("-1");
parse("1.5");
parse("NaN");
parse("Infinity");
parse("-Infinity");
parse("a");
clamp
Clamps a number
between a minimum and a maximum value.
import * as S from "@effect/schema/Schema";
const schema = S.number.pipe(S.clamp(-1, 1));
const parse = S.parseSync(schema);
parse(-3);
parse(0);
parse(3);
Symbol transformations
symbol
Transforms a string
into a symbol
by parsing the string using Symbol.for
.
import * as S from "@effect/schema/Schema";
const schema = S.symbol;
const parse = S.parseSync(schema);
parse("a");
Bigint transformations
bigint
Transforms a string
into a bigint
by parsing the string using BigInt
.
import * as S from "@effect/schema/Schema";
const schema = S.bigint;
const parse = S.parseSync(schema);
parse("1");
parse("-1");
parse("a");
parse("1.5");
parse("NaN");
parse("Infinity");
parse("-Infinity");
BigintFromNumber
Transforms a number
into a bigint
by parsing the number using BigInt
.
import * as S from "@effect/schema/Schema";
const schema = S.BigintFromNumber;
const parse = S.parseSync(schema);
const encode = S.encodeSync(schema);
parse(1);
parse(-1);
encode(1n);
encode(-1n);
parse(1.5);
parse(NaN);
parse(Infinity);
parse(-Infinity);
encode(BigInt(Number.MAX_SAFE_INTEGER) + 1n);
encode(BigInt(Number.MIN_SAFE_INTEGER) - 1n);
clamp
Clamps a bigint
between a minimum and a maximum value.
import * as S from "@effect/schema/Schema";
const schema = S.bigint.pipe(S.clampBigint(-1n, 1n));
const parse = S.parseSync(schema);
parse(-3n);
parse(0n);
parse(3n);
Boolean transformations
not
Negates a boolean value.
import * as S from "@effect/schema/Schema";
const schema = S.boolean.pipe(S.not);
const parse = S.parseSync(schema);
parse(true);
parse(false);
Date transformations
Date
Transforms a string
into a valid Date
.
import * as S from "@effect/schema/Schema";
const schema = S.Date;
const parse = S.parseSync(schema);
parse("1970-01-01T00:00:00.000Z");
parse("a");
const validate = S.validateSync(schema);
validate(new Date(0));
validate(new Date("fail"));
Interop with effect/Data
The effect/Data
module in the Effect ecosystem serves as a utility module that simplifies the process of comparing values for equality without the need for explicit implementations of the Equal
and Hash
interfaces. It provides convenient APIs that automatically generate default implementations for equality checks, making it easier for developers to perform equality comparisons in their applications.
import * as Data from "effect/Data";
import * as Equal from "effect/Equal";
const person1 = Data.struct({ name: "Alice", age: 30 });
const person2 = Data.struct({ name: "Alice", age: 30 });
console.log(Equal.equals(person1, person2));
You can use the Schema.data(schema)
combinator to build a schema from an existing schema that can decode a value A
to a value Data<A>
:
const schema = S.data(
S.struct({
name: S.string,
age: S.number
})
);
const decode = S.decode(schema);
const person1 = decode({ name: "Alice", age: 30 });
const person2 = decode({ name: "Alice", age: 30 });
console.log(Equal.equals(person1, person2));
Option
Parsing from nullable fields
The optionFromNullable
combinator in @effect/schema/Schema
allows you to specify that a field in a schema is of type Option<A>
and can be parsed from a required nullable field A | null
. This is particularly useful when working with JSON data that may contain null
values for optional fields.
When parsing a nullable field, the option
combinator follows these conversion rules:
null
parses to None
A
parses to Some<A>
Here's an example that demonstrates how to use the optionFromNullable
combinator:
import * as Schema from "@effect/schema/Schema";
import { Option } from "effect";
const schema = Schema.struct({
a: Schema.string,
b: Schema.optionFromNullable(Schema.number)
});
const parseOrThrow = Schema.parseSync(schema);
console.log(parseOrThrow({ a: "hello", b: null }));
console.log(parseOrThrow({ a: "hello", b: 1 }));
parseOrThrow({ a: "hello", b: undefined });
parseOrThrow({ a: "hello" });
const encodeOrThrow = Schema.encodeSync(schema);
console.log(encodeOrThrow({ a: "hello", b: Option.none() }));
console.log(encodeOrThrow({ a: "hello", b: Option.some(1) }));
ReadonlySet
In the following section, we demonstrate how to use the readonlySet
combinator to parse a ReadonlySet
from an array of values.
import * as S from "@effect/schema/Schema";
const schema = S.readonlySet(S.number);
const parse = S.parseSync(schema);
parse([1, 2, 3]);
ReadonlyMap
In the following section, we demonstrate how to use the readonlyMap
combinator to parse a ReadonlyMap
from an array of entries.
import * as S from "@effect/schema/Schema";
const schema = S.readonlyMap(S.number, S.string);
const parse = S.parseSync(schema);
parse([
[1, "a"],
[2, "b"],
[3, "c"]
]);
Adding new data types
The easiest way to define a new data type is through the filter
combinator.
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.parseSync(LongString)("a"));
It is good practice to add as much metadata as possible so that it can be used later by introspecting the 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"
})
);
Technical overview
Understanding Schemas
A schema is a description of a data structure that can be used to generate various artifacts from a single declaration.
From a technical point of view a schema is just a typed wrapper of an AST
value:
interface Schema<I, A> {
readonly ast: AST;
}
The AST
type represents a tiny portion of the TypeScript AST, roughly speaking the part describing ADTs (algebraic data types),
i.e. products (like structs and tuples) and unions, plus a custom transformation node.
This means that you can define your own schema constructors / combinators as long as you are able to manipulate the AST
value accordingly, let's see an example.
Say we want to define a pair
schema constructor, which takes a Schema<A>
as input and returns a Schema<readonly [A, A]>
as output.
First of all we need to define the signature of pair
import * as S from "@effect/schema/Schema";
declare const pair: <A>(schema: S.Schema<A>) => S.Schema<readonly [A, A]>;
Then we can implement the body using the APIs exported by the @effect/schema/AST
module:
import * as S from "@effect/schema/Schema";
import * as AST from "@effect/schema/AST";
import * as Option from "effect/Option";
const pair = <A>(schema: S.Schema<A>): S.Schema<readonly [A, A]> => {
const element = AST.createElement(
schema.ast,
false
);
const tuple = AST.createTuple(
[element, element],
Option.none(),
true
);
return S.make(tuple);
};
This example demonstrates the use of the low-level APIs of the AST
module, however, the same result can be achieved more easily and conveniently by using the high-level APIs provided by the Schema
module.
const pair = <A>(schema: S.Schema<A>): S.Schema<readonly [A, A]> =>
S.tuple(schema, schema);
Annotations
One of the fundamental requirements in the design of @effect/schema
is that it is extensible and customizable. Customizations are achieved through "annotations". Each node contained in the AST of @effect/schema/AST
contains an annotations: Record<string | symbol, unknown>
field that can be used to attach additional information to the schema.
Let's see some examples:
import { pipe } from "effect/Function";
import * as S from "@effect/schema/Schema";
const Password =
S.string.pipe(
S.message(() => "not a string"),
S.nonEmpty({ message: () => "required" }),
S.maxLength(10, { message: (s) => `${s} is too long` }),
S.identifier("Password"),
S.title("password"),
S.description(
"A password is a string of characters used to verify the identity of a user during the authentication process"
),
S.examples(["1Ki77y", "jelly22fi$h"]),
S.documentation(`
jsDoc documentation...
`)
);
The example shows some built-in combinators to add meta information, but users can easily add their own meta information by defining a custom combinator.
Here's an example of how to add a deprecated
annotation:
import * as S from "@effect/schema/Schema";
import * as AST from "@effect/schema/AST";
const DeprecatedId = "some/unique/identifier/for/the/custom/annotation";
const deprecated = <A>(self: S.Schema<A>): S.Schema<A> =>
S.make(AST.setAnnotation(self.ast, DeprecatedId, true));
const schema = S.string.pipe(deprecated);
console.log(schema);
Annotations can be read using the getAnnotation
helper, here's an example:
import * as Option from "effect/Option";
import { pipe } from "effect/Function";
const isDeprecated = <A>(schema: S.Schema<A>): boolean =>
pipe(
AST.getAnnotation<boolean>(DeprecatedId)(schema.ast),
Option.getOrElse(() => false)
);
console.log(isDeprecated(S.string));
console.log(isDeprecated(schema));
Documentation
License
The MIT License (MIT)
Contributing Guidelines
Thank you for considering contributing to our project! Here are some guidelines to help you get started:
Reporting Bugs
If you have found a bug, please open an issue on our issue tracker and provide as much detail as possible. This should include:
- A clear and concise description of the problem
- Steps to reproduce the problem
- The expected behavior
- The actual behavior
- Any relevant error messages or logs
Suggesting Enhancements
If you have an idea for an enhancement or a new feature, please open an issue on our issue tracker and provide as much detail as possible. This should include:
- A clear and concise description of the enhancement or feature
- Any potential benefits or use cases
- Any potential drawbacks or trade-offs
Pull Requests
We welcome contributions via pull requests! Here are some guidelines to help you get started:
- Fork the repository and clone it to your local machine.
- Create a new branch for your changes:
git checkout -b my-new-feature
- Install dependencies:
pnpm install
(pnpm@8.x
) - Make your changes and add tests if applicable.
- Run the tests:
pnpm test
- Create a changeset for your changes: before committing your changes, create a changeset to document the modifications. This helps in tracking and communicating the changes effectively. To create a changeset, run the following command:
pnpm changeset
. - Commit your changes: after creating the changeset, commit your changes with a descriptive commit message:
git commit -am 'Add some feature'
. - Push your changes to your fork:
git push origin my-new-feature
. - Open a pull request against our
main
branch.
Pull Request Guidelines
- Please make sure your changes are consistent with the project's existing style and conventions.
- Please write clear commit messages and include a summary of your changes in the pull request description.
- Please make sure all tests pass and add new tests as necessary.
- If your change requires documentation, please update the relevant documentation.
- Please be patient! We will do our best to review your pull request as soon as possible.
License
By contributing to this project, you agree that your contributions will be licensed under the project's MIT License.