0.0.0-20240204124243 • Published 4 months ago

@effect-app/schema-fork v0.0.0-20240204124243

Weekly downloads
-
License
MIT
Repository
github
Last release
4 months ago

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

OperationDescription
DecodingTransforming data from an input type I to an output type A.
EncodingConverting data from an output type A back to an input type I.
AssertingVerifying that a value adheres to the schema's output type A.
ArbitrariesGenerate arbitraries for fast-check testing.
Pretty printingSupport pretty printing for data structures.
JSON SchemasCreate JSON Schemas based on defined schemas.
EquivalenceCreate Equivalences based on defined schemas.

If you're eager to learn how to define your first schema, jump straight to the Basic usage section!

Understanding Decoding and Encoding

sequenceDiagram
    participant U1 as unknown
    participant I
    participant A
    participant U2 as unknown
    U1->>A: decodeUnknown
    I->>A: decode
    A->>I: encode
    U2->>I: encodeUnknown
    U2->>A: validate
    U2->>A: is
    U2->>A: asserts

We'll break down these concepts using an example with a Schema<never, 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.

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 schema: S.Schema<never, {
    readonly myfield?: string; // the type is strict
}, {
    readonly myfield?: string; // the type is strict
}>
*/
const schema = S.struct({
  myfield: S.optional(S.string.pipe(S.nonEmpty()), {
    exact: true,
  }),
});

S.decodeSync(schema)({ myfield: undefined });
/*
TypeScript Error:
Argument of type '{ myfield: undefined; }' is not assignable to parameter of type '{ readonly myfield?: string; }' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
  Types of property 'myfield' are incompatible.
    Type 'undefined' is not assignable to type 'string'.ts(2379)
*/

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 S from "@effect/schema/Schema";

/*
const schema: S.Schema<never, {
    readonly myfield?: string | undefined; // the type is widened to string | undefined
}, {
    readonly myfield?: string | undefined; // the type is widened to string | undefined
}>
*/
const schema = S.struct({
  myfield: S.optional(S.string.pipe(S.nonEmpty()), {
    exact: true,
  }),
});

S.decodeSync(schema)({ myfield: undefined }); // No type error, but a decoding failure occurs
/*
Error: { myfield?: a non empty string }
└─ ["myfield"]
   └─ a non empty string
      └─ From side refinement failure
         └─ Expected a string, actual 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

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.

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 Schemas, including functions for defining arrays, tuples, and records.

Extracting Inferred Types

After you've defined a Schema<R, I, A>, you can extract the inferred type A that represents the data described by the schema using the Schema.To type.

For instance, with the Person schema we defined earlier, 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.number,
});

interface Person extends S.Schema.To<typeof Person> {}
/*
Equivalent to:
interface Person {
  readonly name: string;
  readonly age: number;
}
*/

Alternatively you can also extract a type instead of an interface:

type Person = S.Schema.To<typeof Person>;
/*
Equivalent to:
type Person {
  readonly name: string;
  readonly age: number;
}
*/

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

// type Context = never
type Context = S.Schema.Context<typeof Person>;

Advanced extracting Inferred Types

In cases where I differs from A, you can also extract the inferred I type using Schema.From.

import type * as S from "@effect/schema/Schema";

// type To = number
type To = S.Schema.To<typeof S.NumberFromString>;

// type From = string
type From = S.Schema.From<typeof S.NumberFromString>;

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.To<typeof _Person> {}

// Re-declare the schema to create a schema with an opaque type
const Person: S.Schema<never, Person> = _Person;

Alternatively, you can use Schema.Class (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.Schema<never, {
    readonly name: string;
    readonly age: string;
}, {
    readonly name: string;
    readonly age: number;
}>
*/
const _Person = S.struct({
  name: S.string,
  age: S.NumberFromString,
});

interface Person extends S.Schema.To<typeof _Person> {}

interface PersonFrom extends S.Schema.From<typeof _Person> {}

// Re-declare the schema to create a schema with an opaque type
const Person: S.Schema<never, PersonFrom, Person> = _Person;

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

Decoding From Unknown

To decode a value from an unknown value using the previously defined Schema, you can make use of the decodeUnknown* functions provided by the @effect/schema/Schema module. Let's explore an example using the decodeUnknownEither function:

import * as S from "@effect/schema/Schema";
import * as Either from "effect/Either";

const Person = S.struct({
  name: S.string,
  age: S.number,
});

const parse = S.decodeUnknownEither(Person);

const input: unknown = { name: "Alice", age: 30 };

const result1 = parse(input);
if (Either.isRight(result1)) {
  console.log(result1.right);
  /*
  Output:
  { name: "Alice", age: 30 }
  */
}

const result2 = parse(null);
if (Either.isLeft(result2)) {
  console.log(result2.left);
  /*
  Output:
  {
    _id: 'ParseError',
    message: 'Expected { name: string; age: number }, actual null'
  }
  */
}

The parse function returns an Either<ParseError, A>, 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<ParseError, A> 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.

Now, let's see another example using the decodeUnknownSync function.

The decodeUnknownSync function is used to parse a value and throw an error if the parsing fails. This is especially useful when you want to ensure that the parsed value adheres to the correct format and are ready to throw an error if it does not.

import * as S from "@effect/schema/Schema";

const Person = S.struct({
  name: S.string,
  age: S.number,
});

try {
  const person = S.decodeUnknownSync(Person)({});
  console.log(person);
} catch (e) {
  console.error("Parsing failed:");
  console.error(e);
}
/*
Parsing failed:
Error: { name: string; age: number }
└─ ["name"]
   └─ is missing
  ...stack...
*/

In this example, we attempt to parse an empty object, but the name property is missing, resulting in an error being thrown.

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
  (id) =>
    Effect.succeed({ id, name: "name", age: 18 }).pipe(
      Effect.delay("10 millis")
    ),
  (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": "is forbidden"
  }
}
*/

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: "bob@example.com",
  })
);
/*
Output:
{ name: 'Bob', age: 40 }
*/

S.decodeUnknownSync(Person)(
  {
    name: "Bob",
    age: 40,
    email: "bob@example.com",
  },
  { 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: "bob@example.com",
    },
    { onExcessProperty: "preserve" }
  )
);
/*
{ email: 'bob@example.com', name: 'Bob', age: 40 }
*/

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: "bob@example.com",
  },
  { errors: "all", onExcessProperty: "error" }
);
/*
throws
Error: { name: string; age: number }
├─ ["email"]
│  └─ is unexpected, expected "name" | "age"
└─ ["age"]
   └─ Expected a number, actual "abc"
*/

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 Either from "effect/Either";

// 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.string,
  age: Age,
});

const encoded = S.encodeEither(Person)({ name: "Alice", age: 30 });
if (Either.isRight(encoded)) {
  console.log(encoded.right);
  /*
  Output:
  { name: "Alice", age: "30" }
  */
}

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 { 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("Parsing failed:");
  console.error(formatError(result.left));
}
/*
Parsing failed:
{ name: string; age: number }
└─ ["name"]
   └─ is missing
*/

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:

export interface Issue {
  readonly _tag:
    | "Transform"
    | "Type"
    | "Forbidden"
    | "Declaration"
    | "Refinement"
    | "Tuple"
    | "TypeLiteral"
    | "Missing"
    | "Unexpected"
    | "Union";
  readonly path: ReadonlyArray<PropertyKey>;
  readonly message: string;
}

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 });

fast-check arbitraries

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 * as Arbitrary from "@effect/schema/Arbitrary";
import * as S from "@effect/schema/Schema";
import * as fc from "fast-check";

const Person = S.struct({
  name: S.string,
  age: S.string.pipe(S.compose(S.NumberFromString), S.int()),
});

/*
fc.Arbitrary<{
    readonly name: string;
    readonly age: number;
}>
*/
const PersonArbitraryTo = Arbitrary.make(Person)(fc);

console.log(fc.sample(PersonArbitraryTo, 2));
/*
Output:
[ { name: 'iP=!', age: -6 }, { name: '', age: 14 } ]
*/

/*
Arbitrary for the "From" type:
fc.Arbitrary<{
    readonly name: string;
    readonly age: string;
}>
*/
const PersonArbitraryFrom = Arbitrary.make(S.from(Person))(fc);

console.log(fc.sample(PersonArbitraryFrom, 2));
/*
Output:
[ { name: '{F', age: '$"{|' }, { name: 'nB}@BK', age: '^V+|W!Z' } ]
*/

Troubleshooting: Dealing with "type": "module" in package.json

If you have set the "type" field in your package.json to "module", you might encounter the following error:

import * as S from "@effect/schema/Schema";
import * as Arbitrary from "@effect/schema/Arbitrary";
import * as fc from "fast-check";

const arb = Arbitrary.make(S.string)(fc);
/*
...more lines...
  Types have separate declarations of a private property 'internalRng'.
*/

To address this issue, you can apply a patch, for example using pnpm patch, to the fast-check package in the node_modules directory:

diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 41d6274a9d4bb2d9924fb82f77e502f232fd12f5..0000000000000000000000000000000000000000
diff --git a/package.json b/package.json
index e871dfde5f8877b1b7de9bd3d9a6e3e4f7f59843..819035d70e22d246c615bda25183db9b5e124287 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
         "default": "./lib/fast-check.js"
       },
       "import": {
-        "types": "./lib/esm/types/fast-check.d.ts",
+        "types": "./lib/types/fast-check.d.ts",
         "default": "./lib/esm/fast-check.js"
       }
     }

This patch helps resolve the issue caused by the declaration of a private property 'internalRng' having separate declarations in the types when using "type": "module" in package.json.

Pretty print

The to 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 to 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 }'
*/

Generating JSON Schemas

The to / from functions, which are part of the @effect/schema/JSONSchema module, allow you to generate a JSON Schema based on a schema definition:

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.to 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.pipe(S.identifier("Name"));
const Age = S.number.pipe(S.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<never, Category> = S.struct({
  name: S.string,
  categories: S.array(S.suspend(() => schema)),
}).pipe(S.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 to 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

Basic usage

Cheatsheet

Typescript TypeDescription / NotesSchema / Combinator
nullS.null
undefinedS.undefined
stringS.string
numberS.number
booleanS.boolean
symbolS.symbolFromSelf / S.symbol
bigintS.bigintFromSelf / S.bigint
unknownS.unknown
anyS.any
neverS.never
objectS.object
unique symbolS.uniqueSymbol
"a", 1, truetype literalsS.literal("a"), S.literal(1), S.literal(true)
a${string}template literalsS.templateLiteral(S.literal("a"), S.string)
{ readonly a: string, readonly b: number }structsS.struct({ a: S.string, b: S.number })
{ readonly a?: string }optional fieldsS.struct({ a: S.optional(S.string, { exact: true }) })
Record<A, B>recordsS.record(A, B)
readonly [string, number]tuplesS.tuple(S.string, S.number)
ReadonlyArray<string>arraysS.array(S.string)
A \| BunionsS.union(A, B)
A & Bintersections of non-overlapping structsS.extend(A, B)
Record<A, B> & Record<C, D>intersections of non-overlapping recordsS.extend(S.record(A, B), S.record(C, D))
type A = { readonly a: A \| null }recursive typesS.struct({ a: S.union(S.null, S.suspend(() => self)) })
keyof AS.keyof(A)
Partial<A>S.partial(A)
Required<A>S.required(A)

Primitives

import * as S from "@effect/schema/Schema";

// primitive values
S.string;
S.number;
S.bigint; // Schema<never, string, bigint>
S.boolean;
S.symbol; // Schema<never, string, symbol>
S.object;

// empty types
S.undefined;
S.void; // accepts undefined

// catch-all types
// allows any value
S.any;
S.unknown;

// never type
// allows no values
S.never;

S.UUID;
S.ULID;

Literals

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);

Template literals

The templateLiteral combinator allows you to create a schema for a TypeScript template literal type.

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, `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");

// $ExpectType Schema<never, "welcome_email_id" | "email_heading_id" | "footer_title_id" | "footer_sendoff_id">
S.templateLiteral(S.union(EmailLocaleIDs, FooterLocaleIDs), S.literal("_id"));

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
  ...stack...
*/

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(error) 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(
          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.

String filters

import * as S from "@effect/schema/Schema";

S.string.pipe(S.maxLength(5));
S.string.pipe(S.minLength(5));
S.NonEmpty; // same as S.string.pipe(S.maxLength(1))
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()); // verifies that a string contains no leading or trailing whitespaces
S.string.pipe(S.lowercased()); // verifies that a string is lowercased

Note: The trimmed combinator does not make any transformations, it only validates. If what you were looking for was a combinator to trim strings, then check out the trim combinator ot the Trim schema.

Number filters

import * as S from "@effect/schema/Schema";

S.number.pipe(S.greaterThan(5));
S.number.pipe(S.greaterThanOrEqualTo(5));
S.number.pipe(S.lessThan(5));
S.number.pipe(S.lessThanOrEqualTo(5));
S.number.pipe(S.between(-2, 2)); // -2 <= x <= 2

S.number.pipe(S.int()); // value must be an integer

S.number.pipe(S.nonNaN()); // not NaN
S.number.pipe(S.finite()); // ensures that the value being parsed is finite and not equal to Infinity or -Infinity

S.number.pipe(S.positive()); // > 0
S.number.pipe(S.nonNegative()); // >= 0
S.number.pipe(S.negative()); // < 0
S.number.pipe(S.nonPositive()); // <= 0

S.number.pipe(S.multipleOf(5)); // evenly divisible by 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)); // -2n <= x <= 2n

S.bigint.pipe(S.positiveBigint()); // > 0n
S.bigint.pipe(S.nonNegativeBigint()); // >= 0n
S.bigint.pipe(S.negativeBigint()); // < 0n
S.bigint.pipe(S.nonPositiveBigint()); // <= 0n

BigDecimal filters

import * as S from "@effect/schema/Schema";
import * as BigDecimal from "effect/BigDecimal";

S.BigDecimal.pipe(S.greaterThanBigDecimal(BigDecimal.fromNumber(5)));
S.BigDecimal.pipe(S.greaterThanOrEqualToBigDecimal(BigDecimal.fromNumber(5)));
S.BigDecimal.pipe(S.lessThanBigDecimal(BigDecimal.fromNumber(5)));
S.BigDecimal.pipe(S.lessThanOrEqualToBigDecimal(BigDecimal.fromNumber(5)));
S.BigDecimal.pipe(
  S.betweenBigDecimal(BigDecimal.fromNumber(-2), BigDecimal.fromNumber(2))
);

S.BigDecimal.pipe(S.positiveBigDecimal());
S.BigDecimal.pipe(S.nonNegativeBigDecimal());
S.BigDecimal.pipe(S.negativeBigDecimal());
S.BigDecimal.pipe(S.nonPositiveBigDecimal());

Duration filters

import * as S from "@effect/schema/Schema";

S.Duration.pipe(S.greaterThanDuration("5 seconds"));
S.Duration.pipe(S.greaterThanOrEqualToDuration("5 seconds"));
S.Duration.pipe(S.lessThanDuration("5 seconds"));
S.Duration.pipe(S.lessThanOrEqualToDuration("5 seconds"));
S.Duration.pipe(S.betweenDuration("5 seconds", "10 seconds"));

Array filters

import * as S from "@effect/schema/Schema";

S.array(S.number).pipe(S.maxItems(2)); // max array length
S.array(S.number).pipe(S.minItems(2)); // min array length
S.array(S.number).pipe(S.itemsCount(2)); // exact array length

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.To<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.To<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,
}

// $ExpectType Schema<never, Fruits>
S.enums(Fruits);

Nullables

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, string | null>
S.nullable(S.string);

// $ExpectType Schema<never, string | null | undefined>
S.nullish(S.string);

Unions

@effect/schema/Schema includes a built-in union combinator for composing "OR" types.

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, string | number>
S.union(S.string, S.number);

Union of literals

While the following is perfectly acceptable:

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, "a" | "b" | "c">
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:

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, "a" | "b" | "c">
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 * as assert from "node:assert";

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") }))), // Add a "kind" property with the literal value "circle" to Circle
      (circle) => ({ ...circle, kind: "circle" as const }), // Add the discriminant property to Circle
      ({ kind: _kind, ...rest }) => rest // Remove the discriminant property
    )
  ),
  Square.pipe(
    S.transform(
      Square.pipe(S.extend(S.struct({ kind: S.literal("square") }))), // Add a "kind" property with the literal value "square" to Square
      (square) => ({ ...square, kind: "square" as const }), // Add the discriminant property to Square
      ({ kind: _kind, ...rest }) => rest // Remove the discriminant property
    )
  )
);

assert.deepStrictEqual(
  S.decodeUnknownSync(DiscriminatedShape)({ radius: 10 }),
  {
    kind: "circle",
    radius: 10,
  }
);

assert.deepStrictEqual(
  S.decodeUnknownSync(DiscriminatedShape)({ sideLength: 10 }),
  {
    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:

import * as S from "@effect/schema/Schema";
import * as assert from "node:assert";

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"))
);

// decoding
assert.deepStrictEqual(
  S.decodeUnknownSync(DiscriminatedShape)({ radius: 10 }),
  {
    kind: "circle",
    radius: 10,
  }
);

// encoding
assert.deepStrictEqual(
  S.encodeSync(DiscriminatedShape)({
    kind: "circle",
    radius: 10,
  }),
  { radius: 10 }
);

Tuples

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, readonly [string, number]>
S.tuple(S.string, S.number);

Append a required element

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, readonly [string, number, boolean]>
S.tuple(S.string, S.number).pipe(S.element(S.boolean));

Append an optional element

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, readonly [string, number, boolean?]>
S.tuple(S.string, S.number).pipe(S.optionalElement(S.boolean));

Append a rest element

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, readonly [string, number, ...boolean[]]>
S.tuple(S.string, S.number).pipe(S.rest(S.boolean));

Arrays

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, readonly number[]>
S.array(S.number);

Mutable Arrays

By default, when you use S.array, it generates a type marked as readonly. The mutable combinator is a useful function for creating a new schema with a mutable type in a shallow manner:

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, number[]>
S.mutable(S.array(S.number));

Non empty arrays

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, readonly [number, ...number[]]>
S.nonEmptyArray(S.number);

Structs

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, { readonly a: string; readonly b: number; }>
S.struct({ a: S.string, b: S.number });

Mutable Properties

By default, when you use S.struct, it generates a type with properties that are marked as readonly. The mutable combinator is a useful function for creating a new schema with properties made mutable in a shallow manner:

import * as S from "@effect/schema/Schema";

// $ExpectType Schema<never, { a: string; b: number; }>
S.mutable(S.struct({ a: S.string, b: S.number }));

Optional fields

Cheatsheet

CombinatorFromTo
optionalSchema<R, I, A>PropertySignature<R, I \| undefined, true, A \| undefined, true>
optionalSchema<R, I, A>, { exact: true }PropertySignature<R, I, true, A, true>

optional(schema)

  • decoding
    • <missing value> -> <missing value>
    • undefined -> undefined
    • i -> a
  • encoding
    • <missing value> -> <missing value>
    • undefined -> undefined
    • a -> i

optional(schema, { exact: true })

  • decoding
    • <missing value> -> <missing value>
    • i -> a
  • encoding
    • <missing value> -> <missing value>
    • a -> i

Default values

CombinatorFromTo
optionalSchema<R, I, A>, { default: () => A }PropertySignature<R, I \| undefined, true, A, false>
optionalSchema<R, I, A>, { exact: true, default: () => A }PropertySignature<R, I, true, A, false>
optionalSchema<R, I, A>, { nullable: true, default: () => A }PropertySignature<R, I \| null \| undefined, true, A, false>
optionalSchema<R, I, A>, { exact: true, nullable: true, default: () => A }PropertySignature<R, I \| null, true, A, false>

optional(schema, { default: () => A })

  • decoding
    • <missing value> -> <default value>
    • undefined -> <default value>
    • i -> a
  • encoding
    • a -> i

optional(schema, { exact: true, default: () => A })

  • decoding
    • <missing value> -> <default value>
    • i -> a
  • encoding
    • a -> i

optional(schema, { nullable: true, default: () => A })

  • decoding
    • <missing value> -> <default value>
    • undefined -> <default value>
    • null -> <default value>
    • i -> a
  • encoding
    • a -> i

optional(schema, { exact: true, nullable: true, default: () => A })

  • decoding
    • <missing value> -> <default value>
    • null -> <default value>
    • i -> a
  • encoding
    • a -> i

Optional fields as Options

CombinatorFromTo
optionalSchema<R, I, A>, { as: "Option" }PropertySignature<R, I \| undefined, true, Option<A>, false>
optionalSchema<R, I, A>, { exact: true, as: "Option" }PropertySignature<R, I, true, Option<A>, false>
optionalSchema<R, I, A>, { nullable: true, as: "Option" }PropertySignature<R, I \| undefined \| null, true, Option<A>, false>
optionalSchema<R, I, A>, { exact: true, nullable: true, as: "Option" }PropertySignature<R, I \| null, true, Option<A>, false>

optional(schema, { as: "Option" })

  • decoding
    • <missing value> -> Option.none()
    • undefined -> Option.none()
    • i -> Option.some(a)
  • encoding
    • Option.none() -> <missing value>
    • Option.some(a) -> i

optional(schema, { exact: true, as: "Option" })

  • decoding
    • <missing value> -> Option.none()
    • i -> Option.some(a)
  • encoding
    • Option.none() -> <missing value>
    • Option.some(a) -> i

optional(schema, { nullable: true, as: "Option" })

  • decoding
    • <missing value> -> Option.none()
    • undefined -> Option.none()
    • null -> Option.none()
    • i -> Option.some(a)
  • encoding
    • Option.none() -> <missing value>
    • Option.some(a) -> i

optional(schema, { exact: true, nullable: true, as: "Option" })

  • decoding
    • <missing value> -> Option.none()
    • null -> Option.none()
    • i -> Option.some(a)
  • encoding
    • Option.none() -> <missing value>
    • Option.some(a) -> i

Renaming Properties

To rename one or more properties, you can utilize the rename API:

import * as S from "@effect/schema/Schema";

// Original Schema
const originalSchema = S.struct({ a: S.string, b: S.number });

// Renaming the "a" property to "c"
const renamedSchema = S.rename(originalSchema, { a: "c" });

console.log(S.decodeUnknownSync(renamedSchema)({ a: "a", b: 1 }));
// Output: { c: "a", b: 1 }

In the example above, we have an original schema with properties "a" and "b." Using the rename API, we create a new schema where we rename the "a" property to "c." The resulting schema, when used with S.decodeUnknownSync, transforms the input object by renaming the specified property.

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";

// Define your schema by providing the type to `Class` and the desired fields
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: "" });
/* throws
Error: { id: number; name: a non empty string }
└─ ["name"]
   └─ a non empty string
      └─ Predicate refinement failure
         └─ Expected a non empty string, actual ""
*/

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" });

console.log(john.upperName); // "JOHN"

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.

import * as S from "@effect/schema/Schema";

class Person extends S.Class<Person>()({
  id: S.number,
  name: S.string.pipe(S.nonEmpty()),
}) {}

console.log(S.isSchema(Person)); // true

// $ExpectType Schema<never, { readonly id: number; name: string; }, { readonly id: number; name: string; }>
Person.struct;

Tagged Class variants

You can also create classes that extend TaggedClass & TaggedError from the effect/Data module:

import * as S from "@effect/schema/Schema";

class TaggedPerson extends S.TaggedClass<TaggedPerson>()("TaggedPerson", {
  name: S.string,
}) {}

class HttpError extends S.TaggedError<HttpError>()("HttpError", {
  status: S.number,
}) {}

const joe = new TaggedPerson({ name: "Joe" });
console.log(joe._tag); // "TaggedPerson"

const error = new HttpError({ status: 404 });
console.log(error._tag); // "HttpError"
console.log(error.stack); // access the stack trace

Extending existing Classes

In situations where you need to augment your existing class with more fields, the built-in extend utility comes in handy:

import * 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();
  }
}

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 ParseResult from "@effect/schema/ParseResult";
import * as S from "@effect/schema/Schema";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";

export class Person extends S.Class<Person>()({
  id: S.number,
  name: S.string,
}) {}

console.log(S.decodeUnknownSync(Person)({ id: 1, name: "name" }));
/*
Output:
Person { id: 1, name: 'name' }
*/

function getAge(id: number): Effect.Effect<never, Error, number> {
  return Effect.succeed(id + 2);
}

export class PersonWithTransform extends Person.transformOrFail<PersonWithTransform>()(
  {
    age: S.optional(S.number, { exact: true, as: "Option" }),
  },
  (input) =>
    Effect.mapBoth(getAge(input.id), {
      onFailure: (e) => ParseResult.type(S.string.ast, input.id, e.message),
      // must return { age: Option<number> }
      onSuccess: (age) => ({ ...input, age: Option.some(age) }),
    }),
  ParseResult.succeed
) {}

S.decodeUnknownPromise(PersonWithTransform)({ id: 1, name: "name" }).then(
  console.log
);
/*
Output:
PersonWithTransform {
  id: 1,
  name: 'name',
  age: { _id: 'Option', _tag: 'Some', value: 3 }
}
*/

export class PersonWithTransformFrom extends Person.transformOrFailFrom<PersonWithTransformFrom>()(
  {
    age: S.optional(S.number, { exact: true, as: "Option" }),
  },
  (input) =>
    Effect.mapBoth(getAge(input.id), {
      onFailure: (e) => ParseResult.type(S.string.ast, input, e.message),
      // must return { age?: number }
      onSuccess: (age) => (age > 18 ? { ...input, age } : { ...input }),
    }),
  ParseResult.succeed
) {}

S.decodeUnknownPromise(PersonWithTransformFrom)({ id: 1, name: "name" }).then(
  console.log
);
/*
Output:
PersonWithTransformFrom {
  id: 1,
  name: 'name',
  age: