Advanced Usage
Declaring New Data Types
Primitive Data Types
To declare a schema for an opaque, non-generic data type, you can use Schema.declare with a type guard. The following example shows the low-level pattern with File.
Example (Declaring a Schema for File)
import { Schema } from "effect"
// Declare a schema for the File type using a type guardconst FileSchema = Schema.declare( (input: unknown): input is File => input instanceof File,)
const decode = Schema.decodeUnknownSync(FileSchema)
// Decoding a valid File objectconsole.log(decode(new File([], "")))/*Output:File { size: 0, type: '', name: '', lastModified: 1724774163056 }*/
// Decoding an invalid inputdecode(null)/*throwsSchemaError: Expected <Declaration>*/You can add identifier, title, and description annotations to make the declaration easier for people and schema interpreters to understand. identifier and title can also improve the default expected-value message.
- Identifier: a unique name for the schema
- Title: a brief, descriptive title
- Description: a detailed explanation of the schema’s purpose
Example (Declaring a Schema with Annotations)
import { Schema } from "effect"
// Declare a schema for the File type with additional annotationsconst FileSchema = Schema.declare( (input: unknown): input is File => input instanceof File, { // A unique identifier for the schema identifier: "File", // Detailed description of the schema description: "The `File` type in JavaScript", },)
const decode = Schema.decodeUnknownSync(FileSchema)
// Decoding a valid File objectconsole.log(decode(new File([], "")))/*Output:File { size: 0, type: '', name: '', lastModified: 1724774163056 }*/
// Decoding an invalid inputdecode(null)/*throwsSchemaError: Expected File*/Type Constructors
Type constructors are generic types that take one or more types as arguments and return a new type. To define a schema for a type constructor, you can use the Schema.declare function.
Example (Declaring a Schema for ReadonlySet<T>)
import { Effect, Schema, SchemaIssue, SchemaParser, SchemaTransformation,} from "effect"
export const MyReadonlySet = <S extends Schema.Constraint>( // Schema for the elements of the Set item: S,) => Schema.declareConstructor< ReadonlySet<S["Type"]>, ReadonlySet<S["Encoded"]> >()( // Store the schema for the Set's elements [item], // Decoding function ([item]) => (input, ast, options) => { if (input instanceof Set) { // Decode each element in the Set return Effect.map( SchemaParser.decodeUnknownEffect(Schema.Array(item))( Array.from(input.values()), options, ), // Return a ReadonlySet containing the decoded elements (values): ReadonlySet<S["Type"]> => new Set(values), ) } // Handle invalid input return Effect.fail(new SchemaIssue.InvalidType(ast)) }, { expected: "ReadonlySet", // Define the encoding side by linking back to an Array schema toCodec: ([item]) => Schema.link<ReadonlySet<S["Encoded"]>>()( Schema.Array(item), SchemaTransformation.transform({ // Decode an array into a ReadonlySet decode: (values): ReadonlySet<S["Encoded"]> => new Set(values), // Encode a ReadonlySet back into an array encode: (set) => Array.from(set.values()), }), ), }, )
// Define a schema for a ReadonlySet of numbersconst setOfNumbers = MyReadonlySet(Schema.FiniteFromString)
const decode = Schema.decodeUnknownSync(setOfNumbers)
console.log(decode(new Set(["1", "2", "3"]))) // Set(3) { 1, 2, 3 }
// Decode an invalid inputdecode(null)/*throwsSchemaError: Expected ReadonlySet*/
// Decode a Set with an invalid elementdecode(new Set(["1", null, "3"]))/*throwsSchemaError: Expected string at [1]*/Adding Interpreter Annotations
When defining a new data type, schema interpreters such as Arbitrary or Formatter may not know how to handle the new type. This can result in an error, as the interpreter may lack the necessary information for generating instances or producing readable output:
Example (Attempting to Generate Arbitrary Values Without Required Annotations)
import { Schema } from "effect"
// Define a schema for the File typeconst FileSchema = Schema.declare( (input: unknown): input is File => input instanceof File, { identifier: "File", },)
// Try creating an Arbitrary instance for the schemaconst arb = Schema.toArbitrary(FileSchema)/*throws:Error: Missing annotationdetails: Generating an Arbitrary for this schema requires an "arbitrary" annotationschema (Declaration): File*/In the above example, attempting to generate arbitrary values for FileSchema fails because the interpreter lacks the necessary annotations. To resolve this, provide an annotation for generating arbitrary data:
Example (Adding Arbitrary Annotation for Custom File Schema)
import { Schema } from "effect"import { FastCheck } from "effect/testing"
const FileSchema = Schema.declare( (input: unknown): input is File => input instanceof File, { identifier: "File", // Provide a function to generate random File instances toArbitrary: () => (fc) => fc .tuple(fc.string(), fc.string()) .map(([content, path]) => new File([content], path)), },)
// Create an Arbitrary instance for the schemaconst arb = Schema.toArbitrary(FileSchema)
// Generate sample files using the Arbitrary instanceconst files = FastCheck.sample(arb, 2)console.log(files)/*Example Output:[ File { size: 5, type: '', name: 'C', lastModified: 1706435571176 }, File { size: 1, type: '', name: '98Ggmc', lastModified: 1706435571176 }]*/For more details on how to add annotations for the Arbitrary interpreter, refer to the Arbitrary documentation.
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.
Example (Structural Typing Issue)
type UserId = stringtype Username = string
declare const getUser: (id: UserId) => object
const myUsername: Username = "gcanti"
getUser(myUsername) // This erroneously worksIn 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 prevent this, Effect introduces branded types. These types attach a unique identifier (or “brand”) to a type, allowing you to differentiate between structurally similar but semantically distinct types.
Example (Defining Branded Types)
import { Brand } from "effect"
type UserId = string & Brand.Brand<"UserId">type Username = string
declare const getUser: (id: UserId) => object
const myUsername: Username = "gcanti"
// @errors: 2345getUser(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/Brandand want to reuse it to define a schema
Defining a brand schema from scratch
To define a schema for a branded type from scratch, use the Schema.brand function.
Example (Creating a schema for a Branded Type)
import { Schema } from "effect"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
// string & Brand<"UserId">type UserId = typeof UserId.TypeReusing an existing branded constructor
If you have already defined a branded type using the effect/Brand module, you can reuse it to define a schema using the Schema.fromBrand function.
Example (Reusing an Existing Branded Type)
import { Schema } from "effect"import { Brand } from "effect"
// the existing branded typetype UserId = string & Brand.Brand<"UserId">
const UserId = Brand.nominal<UserId>()
// Define a schema for the branded typeconst UserIdSchema = Schema.String.pipe(Schema.fromBrand("UserId", UserId))Utilizing Default Constructors
The Schema.brand function includes a default constructor to facilitate the creation of branded values.
import { Schema } from "effect"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
const userId = UserId.make("123") // => "123"Property Signatures
Property signature combinators control a struct field independently on the encoded and decoded sides. They can make a key optional, allow undefined, provide defaults, attach key-level annotations, or rename the encoded key.
Basic Usage
A property signature can be defined with annotations to provide additional context about a field.
Example (Adding Annotations to a Property Signature)
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.FiniteFromString.pipe( Schema.annotateKey({ title: "Age", // Annotation to label the age field }), ),})Use Schema.annotateKey for field metadata. When the external representation uses a different key, use Schema.encodeKeys on the struct.
Example (Mapping from a Different Key)
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.FiniteFromString, // Maps from "AGE" to "age"}).pipe(Schema.encodeKeys({ age: "AGE" }))
console.log(Schema.decodeUnknownSync(Person)({ name: "name", AGE: "18" }))// Output: { name: 'name', age: 18 }Optional Fields
Basic Optional Property
Schema.optional makes a key optional and allows undefined when the key is present.
Decoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
undefined |
remains undefined |
e: E |
transforms to t: T |
Encoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
undefined |
remains undefined |
t: T |
transforms back to e: E |
Example (Defining an Optional Number Field)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optional(Schema.FiniteFromString),})
// ┌─── { readonly quantity?: string | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity?: number | undefined; }// ▼type Type = typeof Product.Type
// Decoding examples
console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" }))// Output: { quantity: 1 }console.log(Schema.decodeUnknownSync(Product)({}))// Output: {}console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: undefined }
// Encoding examples
console.log(Schema.encodeSync(Product)({ quantity: 1 }))// Output: { quantity: "1" }console.log(Schema.encodeSync(Product)({}))// Output: {}console.log(Schema.encodeSync(Product)({ quantity: undefined }))// Output: { quantity: undefined }Optional with Nullability
Combine Schema.optional, Schema.NullOr, and an optional-field transformation when null should be treated as a missing value.
Decoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
undefined |
remains undefined |
null |
transforms to <missing value> |
e: E |
transforms to t: T |
Encoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
undefined |
remains undefined |
t: T |
transforms back to e: E |
Example (Handling Null as Missing Value)
import { Option, Predicate, Schema, SchemaGetter } from "effect"
const Product = Schema.Struct({ quantity: Schema.optional(Schema.NullOr(Schema.FiniteFromString)).pipe( Schema.decodeTo(Schema.optional(Schema.Finite), { decode: SchemaGetter.transformOptional((o) => o.pipe(Option.filter(Predicate.isNotNull)), ), encode: SchemaGetter.transformOptional((o) => o), }), ),})
// ┌─── { readonly quantity?: string | null | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity?: number | undefined; }// ▼type Type = typeof Product.Type
// Decoding examples
console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" }))// Output: { quantity: 1 }console.log(Schema.decodeUnknownSync(Product)({}))// Output: {}console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: undefined }console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: {}
// Encoding examples
console.log(Schema.encodeSync(Product)({ quantity: 1 }))// Output: { quantity: "1" }console.log(Schema.encodeSync(Product)({}))// Output: {}console.log(Schema.encodeSync(Product)({ quantity: undefined }))// Output: { quantity: undefined }Exact Optional Key
Schema.optionalKey makes the key optional without adding undefined to its value type. If the key is present, its value must be accepted by the wrapped schema.
Decoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
undefined |
SchemaError |
e: E |
transforms to t: T |
Encoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
t: T |
transforms back to e: E |
Example (Using Exactness with Optional Field)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalKey(Schema.FiniteFromString),})
// ┌─── { readonly quantity?: string; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity?: number; }// ▼type Type = typeof Product.Type
// Decoding examples
console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" }))// Output: { quantity: 1 }console.log(Schema.decodeUnknownSync(Product)({}))// Output: {}console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))/*throws:SchemaError: Expected string at ["quantity"]*/
// Encoding examples
console.log(Schema.encodeSync(Product)({ quantity: 1 }))// Output: { quantity: "1" }console.log(Schema.encodeSync(Product)({}))// Output: {}Exact Optional Key with Nullability
Combine Schema.optionalKey, Schema.NullOr, and an optional-field transformation when null should be treated as a missing key but undefined should still be rejected.
Decoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
null |
transforms to <missing value> |
undefined |
SchemaError |
e: E |
transforms to t: T |
Encoding
| Input | Output |
|---|---|
<missing value> |
remains <missing value> |
t: T |
transforms back to e: E |
Example (Using Exactness and Handling Null as Missing Value with Optional Field)
import { Option, Predicate, Schema, SchemaGetter } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalKey(Schema.NullOr(Schema.FiniteFromString)).pipe( Schema.decodeTo(Schema.optionalKey(Schema.Finite), { decode: SchemaGetter.transformOptional((o) => o.pipe(Option.filter(Predicate.isNotNull)), ), encode: SchemaGetter.transformOptional((o) => o), }), ),})
// ┌─── { readonly quantity?: string | null; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity?: number; }// ▼type Type = typeof Product.Type
// Decoding examples
console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" }))// Output: { quantity: 1 }console.log(Schema.decodeUnknownSync(Product)({}))// Output: {}console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))/*throws:SchemaError: Expected string | null at ["quantity"]*/console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: {}
// Encoding examples
console.log(Schema.encodeSync(Product)({ quantity: 1 }))// Output: { quantity: "1" }console.log(Schema.encodeSync(Product)({}))// Output: {}Representing Optional Fields with never Type
When creating a schema to replicate a TypeScript type that includes optional fields with the never type, like:
type MyType = { readonly quantity?: never}the handling of these fields depends on the exactOptionalPropertyTypes setting in your tsconfig.json.
This setting affects whether the schema should treat optional never-typed fields as simply absent or allow undefined as a value.
Example (exactOptionalPropertyTypes: false)
When this feature is turned off, you can employ the Schema.optional function. This approach allows the field to implicitly accept undefined as a value.
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optional(Schema.Never),})
// ┌─── { readonly quantity?: undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity?: undefined; }// ▼type Type = typeof Product.TypeExample (exactOptionalPropertyTypes: true)
When this feature is turned on, use Schema.optionalKey so that the field can only be absent.
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalKey(Schema.Never),})
// ┌─── { readonly quantity?: never; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity?: never; }// ▼type Type = typeof Product.TypeDefault Values
Use Schema.withDecodingDefaultType or Schema.withDecodingDefaultTypeKey to provide a decoded default. Constructor defaults are independent and can be added with Schema.withConstructorDefault.
Basic Default
This is the simplest use case. If the input is missing or undefined, the default value will be applied.
| Operation | Behavior |
|---|---|
| Decoding | Applies the default value if the input is missing or undefined |
| Encoding | Transforms the input t: T back to e: E |
Example (Applying Default When Field Is Missing or undefined)
import { Effect, Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.FiniteFromString.pipe( Schema.withDecodingDefaultType(Effect.succeed(1)), // Default value for quantity Schema.withConstructorDefault(Effect.succeed(1)), ),})
// ┌─── { readonly quantity?: string | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: number; }// ▼type Type = typeof Product.Type
// Decoding examples with default applied
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: 2 }
// Object construction examples with default applied
console.log(Product.make({}))// Output: { quantity: 1 }
console.log(Product.make({ quantity: 2 }))// Output: { quantity: 2 }Default for a Missing Key
Use Schema.withDecodingDefaultTypeKey when the default should apply only if the key is missing, not when its value is undefined.
| Operation | Behavior |
|---|---|
| Decoding | Applies the default value only if the input is missing |
| Encoding | Transforms the input t: T back to e: E |
Example (Applying Default Only When Field Is Missing)
import { Effect, Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.FiniteFromString.pipe( Schema.withDecodingDefaultTypeKey(Effect.succeed(1)), // Default value for quantity, only if quantity is not provided ),})
// ┌─── { readonly quantity?: string; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: number; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: 2 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))/*throws:SchemaError: Expected string at ["quantity"]*/Default with Nullability
Combine an optional nullable field with SchemaGetter.transformOptional when missing, undefined, and null should all produce the default.
| Operation | Behavior |
|---|---|
| Decoding | Applies the default value if the input is missing or undefined or null |
| Encoding | Transforms the input t: T back to e: E |
Example (Applying Default When Field Is Missing or undefined or null)
import { Option, Predicate, Schema, SchemaGetter } from "effect"
const Product = Schema.Struct({ quantity: Schema.optional(Schema.NullOr(Schema.FiniteFromString)).pipe( Schema.decodeTo(Schema.Finite, { decode: SchemaGetter.transformOptional((o) => o.pipe( Option.filter(Predicate.isNotNullish), Option.orElseSome(() => 1), ), ), encode: SchemaGetter.required(), }), ),})
// ┌─── { readonly quantity?: string | null | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: number; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: 2 }Default for a Missing or Null Key
Use an exact optional nullable field when missing and null should produce the default but undefined should be rejected.
| Operation | Behavior |
|---|---|
| Decoding | Applies the default value if the input is missing or null |
| Encoding | Transforms the input t: T back to e: E |
Example (Applying Default Only When Field Is Missing or null)
import { Option, Predicate, Schema, SchemaGetter } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalKey(Schema.NullOr(Schema.FiniteFromString)).pipe( Schema.decodeTo(Schema.Finite, { decode: SchemaGetter.transformOptional((o) => o.pipe( Option.filter(Predicate.isNotNull), Option.orElseSome(() => 1), ), ), encode: SchemaGetter.required(), }), ),})
// ┌─── { readonly quantity?: string | null; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: number; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: 2 }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))/*throws:SchemaError: Expected string | null at ["quantity"]*/Optional Fields as Options
When working with optional fields, you may want to handle them as Option values. This approach allows you to explicitly manage the presence or absence of a field rather than relying on undefined or null.
Basic Optional with Option Type
Schema.OptionFromOptional converts a missing or undefined field to Option.none() and an existing value to Option.some().
Decoding
| Input | Output |
|---|---|
<missing value> |
transforms to Option.none() |
undefined |
transforms to Option.none() |
e: E |
transforms to Option.some(t: T) |
Encoding
| Input | Output |
|---|---|
Option.none() |
transforms to <missing value> |
Option.some(t: T) |
transforms back to e: E |
Example (Handling Optional Field as Option)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.OptionFromOptional(Schema.FiniteFromString),})
// ┌─── { readonly quantity?: string | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: Option<number>; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }Exact Optional Key as an Option
Schema.OptionFromOptionalKey converts a missing key to Option.none() while rejecting undefined when the key is present.
Decoding
| Input | Output |
|---|---|
<missing value> |
transforms to Option.none() |
undefined |
SchemaError |
e: E |
transforms to Option.some(t: T) |
Encoding
| Input | Output |
|---|---|
Option.none() |
transforms to <missing value> |
Option.some(t: T) |
transforms back to e: E |
Example (Using Exactness with Optional Field as Option)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.OptionFromOptionalKey(Schema.FiniteFromString),})
// ┌─── { readonly quantity?: string; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: Option<number>; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))/*throws:SchemaError: Expected string at ["quantity"]*/Optional with Nullability
Schema.OptionFromOptionalNullOr also treats null as Option.none().
Decoding
| Input | Output |
|---|---|
<missing value> |
transforms to Option.none() |
undefined |
transforms to Option.none() |
null |
transforms to Option.none() |
e: E |
transforms to Option.some(t: T) |
Encoding
| Input | Output |
|---|---|
Option.none() |
transforms to <missing value> |
Option.some(t: T) |
transforms back to e: E |
Example (Handling Null as Missing Value with Optional Field as Option)
import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.OptionFromOptionalNullOr(Schema.FiniteFromString),})
// ┌─── { readonly quantity?: string | null | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: Option<number>; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }Exact Optional Nullable Key as an Option
Combine Schema.optionalKey, Schema.NullOr, and SchemaGetter.transformOptional when missing and null should become Option.none() but undefined should be rejected.
Decoding
| Input | Output |
|---|---|
<missing value> |
transforms to Option.none() |
undefined |
SchemaError |
null |
transforms to Option.none() |
e: E |
transforms to Option.some(t: T) |
Encoding
| Input | Output |
|---|---|
Option.none() |
transforms to <missing value> |
Option.some(t: T) |
transforms back to e: E |
Example (Using Exactness and Handling Null as Missing Value with Optional Field as Option)
import { Option, Predicate, Schema, SchemaGetter } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalKey(Schema.NullOr(Schema.FiniteFromString)).pipe( Schema.decodeTo(Schema.Option(Schema.Finite), { decode: SchemaGetter.transformOptional((o) => Option.some(o.pipe(Option.filter(Predicate.isNotNull))), ), encode: SchemaGetter.transformOptional(Option.flatten), }), ),})
// ┌─── { readonly quantity?: string | null; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: Option<number>; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))/*throws:SchemaError: Expected string | null at ["quantity"]*/Optional Field Transformations
Optional to Optional
Use Schema.decodeTo with SchemaGetter.transformOptional to transform an optional encoded field into an optional decoded field. This lets the transformation decide whether the key is present on either side.
One common use case is treating a specific encoded value, such as an empty string, as an absent field in the decoded output.
The decoding and encoding getters receive an Option: None means the key is absent, and returning None omits it from the output.
Example (Omitting Empty Strings from the Output)
Consider an optional field of type string where empty strings in the input should be removed from the output.
import { Option, Schema, SchemaGetter } from "effect"
const schema = Schema.Struct({ nonEmpty: Schema.optionalKey(Schema.String).pipe( Schema.decodeTo(Schema.optionalKey(Schema.String), { // ┌─── Option<string> // ▼ decode: SchemaGetter.transformOptional((maybeString) => { if (Option.isNone(maybeString)) { // If `maybeString` is `None`, the field is absent in the input. // Return Option.none() to omit it in the output. return Option.none() } // Extract the value from the `Some` instance const value = maybeString.value if (value === "") { // Treat empty strings as missing in the output // by returning Option.none(). return Option.none() } // Include non-empty strings in the output. return Option.some(value) }), // In the encoding phase, you can decide to process the field // similarly to the decoding phase or use a different logic. // Here, the logic is left unchanged. // // ┌─── Option<string> // ▼ encode: SchemaGetter.transformOptional((maybeString) => maybeString), }), ),})
// Decoding examples
const decode = Schema.decodeUnknownSync(schema)
console.log(decode({}))// Output: {}console.log(decode({ nonEmpty: "" }))// Output: {}console.log(decode({ nonEmpty: "a non-empty string" }))// Output: { nonEmpty: 'a non-empty string' }
// Encoding examples
const encode = Schema.encodeSync(schema)
console.log(encode({}))// Output: {}console.log(encode({ nonEmpty: "" }))// Output: { nonEmpty: '' }console.log(encode({ nonEmpty: "a non-empty string" }))// Output: { nonEmpty: 'a non-empty string' }You can simplify the decoding logic with Option.filter, which filters out unwanted values in a concise way.
Example (Using Option.filter for Decoding)
import { identity, Option, Schema, SchemaGetter } from "effect"
const schema = Schema.Struct({ nonEmpty: Schema.optionalKey(Schema.String).pipe( Schema.decodeTo(Schema.optionalKey(Schema.String), { decode: SchemaGetter.transformOptional(Option.filter((s) => s !== "")), encode: SchemaGetter.transformOptional(identity), }), ),})Optional to Required
Use an optional schema on the encoded side and a required schema on the decoded side. SchemaGetter.transformOptional can supply a value when the encoded key is missing and omit selected values during encoding.
Example (Setting null as Default for Missing Field)
This example provides a null value when the encoded field is missing. During encoding, a decoded null value omits the field.
import { Option, Schema, SchemaGetter } from "effect"
const schema = Schema.Struct({ nullable: Schema.optionalKey( // Input schema for an optional string Schema.String, ).pipe( Schema.decodeTo( // Output schema allowing null or string Schema.NullOr(Schema.String), { // ┌─── Option<string> // ▼ decode: SchemaGetter.transformOptional((maybeString) => { if (Option.isNone(maybeString)) { // If `maybeString` is `None`, the field is absent in the input. // Return `null` as the default value for the output. return Option.some(null) } // Extract the value from the `Some` instance // and use it as the output. return Option.some(maybeString.value) }), // During encoding, treat `null` as an absent field // // ┌─── string | null // ▼ encode: SchemaGetter.transformOptional((maybeStringOrNull) => Option.flatMap(maybeStringOrNull, (stringOrNull) => stringOrNull === null ? // Omit the field by returning `None` Option.none() : // Include the field by returning `Some` Option.some(stringOrNull), ), ), }, ), ),})
// Decoding examples
const decode = Schema.decodeUnknownSync(schema)
console.log(decode({}))// Output: { nullable: null }console.log(decode({ nullable: "a value" }))// Output: { nullable: 'a value' }
// Encoding examples
const encode = Schema.encodeSync(schema)
console.log(encode({ nullable: "a value" }))// Output: { nullable: 'a value' }console.log(encode({ nullable: null }))// Output: {}You can streamline the decoding and encoding logic using Option.getOrElse and Option.liftPredicate for concise and readable transformations.
Example (Using Option.getOrElse and Option.liftPredicate)
import { Option, Schema, SchemaGetter } from "effect"
const schema = Schema.Struct({ nullable: Schema.optionalKey(Schema.String).pipe( Schema.decodeTo(Schema.NullOr(Schema.String), { decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)), encode: SchemaGetter.transformOptional( Option.filter((value) => value !== null), ), }), ),})Required to Optional
Use a required schema on the encoded side and an optional schema on the decoded side. The transformation can omit selected decoded values and must restore a required value during encoding.
Example (Handling Empty String as Missing Value)
In this example, the name field is required but treated as optional if it is an empty string. During decoding, an empty string in name is considered absent, while encoding ensures a value (using an empty string as a default if name is absent).
import { Option, Schema, SchemaGetter } from "effect"
const schema = Schema.Struct({ name: Schema.String.pipe( Schema.decodeTo(Schema.optionalKey(Schema.String), { // ┌─── Option<string> // ▼ decode: SchemaGetter.transformOptional((maybeString) => Option.flatMap(maybeString, (string) => { // Treat empty string as a missing value if (string === "") { // Omit the field by returning `None` return Option.none() } // Otherwise, return the string as is return Option.some(string) }), ), // ┌─── Option<string> // ▼ encode: SchemaGetter.transformOptional((maybeString) => { // Check if the field is missing if (Option.isNone(maybeString)) { // Provide an empty string as default return Option.some("") } // Otherwise, return the string as is return maybeString }), }), ),})
// Decoding examples
const decode = Schema.decodeUnknownSync(schema)
console.log(decode({ name: "John" }))// Output: { name: 'John' }console.log(decode({ name: "" }))// Output: {}
// Encoding examples
const encode = Schema.encodeSync(schema)
console.log(encode({ name: "John" }))// Output: { name: 'John' }console.log(encode({}))// Output: { name: '' }You can streamline the decoding and encoding logic using Option.liftPredicate and Option.getOrElse for concise and readable transformations.
Example (Using Option.liftPredicate and Option.getOrElse)
import { Option, Schema, SchemaGetter } from "effect"
const schema = Schema.Struct({ name: Schema.String.pipe( Schema.decodeTo(Schema.optionalKey(Schema.String), { decode: SchemaGetter.transformOptional((maybeString) => Option.flatMap( maybeString, Option.liftPredicate((s) => s !== ""), ), ), encode: SchemaGetter.transformOptional((maybeString) => Option.some(Option.getOrElse(maybeString, () => "")), ), }), ),})Extending Schemas
Struct schemas expose their fields, which you can spread into a new struct or extend with Schema.fieldsAssign. Unions expose mapMembers, so the same field operation can be applied to every struct member.
Spreading Struct fields
Structs provide access to their fields through the fields property, which allows you to extend an existing struct by adding additional fields or combining fields from multiple structs.
Example (Adding New Fields)
import { Schema } from "effect"
const Original = Schema.Struct({ a: Schema.String, b: Schema.String,})
const Extended = Schema.Struct({ ...Original.fields, // Adding new fields c: Schema.String, d: Schema.String,})
// ┌─── {// | readonly a: string;// | readonly b: string;// | readonly c: string;// | readonly d: string;// | }// ▼type Type = typeof Extended.TypeExample (Adding Additional Index Signatures)
import { Schema } from "effect"
const Original = Schema.Struct({ a: Schema.String, b: Schema.String,})
const Extended = Schema.StructWithRest( Schema.Struct(Original.fields), // Adding an index signature [Schema.Record(Schema.String, Schema.String)],)
// ┌─── {// │ readonly [x: string]: string;// | readonly a: string;// | readonly b: string;// | }// ▼type Type = typeof Extended.TypeExample (Combining Fields from Multiple Structs)
import { Schema } from "effect"
const Struct1 = Schema.Struct({ a: Schema.String, b: Schema.String,})
const Struct2 = Schema.Struct({ c: Schema.String, d: Schema.String,})
const Extended = Schema.Struct({ ...Struct1.fields, ...Struct2.fields,})
// ┌─── {// | readonly a: string;// | readonly b: string;// | readonly c: string;// | readonly d: string;// | }// ▼type Type = typeof Extended.TypeThe fieldsAssign Function
Schema.fieldsAssign(fields) is a concise form of struct.mapFields(Struct.assign(fields)). Use it directly on a struct, or map it over every member of a union.
Example (Adding Fields to Every Union Member)
import { Schema, Tuple } from "effect"
const Struct = Schema.Struct({ a: Schema.String,})
const UnionOfStructs = Schema.Union([ Schema.Struct({ b: Schema.String }), Schema.Struct({ c: Schema.String }),])
const Extended = UnionOfStructs.mapMembers( Tuple.map(Schema.fieldsAssign(Struct.fields)),)
// ┌─── {// | readonly a: string;// | } & ({// | readonly b: string;// | } | {// | readonly c: string;// | })// ▼type Type = typeof Extended.TypeRenaming Properties
Renaming a Property During Definition
To use a different key in the encoded representation, apply Schema.encodeKeys after defining the struct.
Example (Renaming a Required Property)
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.String, b: Schema.Finite,}).pipe(Schema.encodeKeys({ a: "c" }))
// ┌─── { readonly c: string; readonly b: number; }// ▼type Encoded = typeof schema.Encoded
// ┌─── { readonly a: string; readonly b: number; }// ▼type Type = typeof schema.Type
console.log(Schema.decodeUnknownSync(schema)({ c: "c", b: 1 }))// Output: { a: "c", b: 1 }Example (Renaming an Optional Property)
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.optional(Schema.String), b: Schema.Finite,}).pipe(Schema.encodeKeys({ a: "c" }))
// ┌─── { readonly b: number; readonly c?: string | undefined; }// ▼type Encoded = typeof schema.Encoded
// ┌─── { readonly a?: string | undefined; readonly b: number; }// ▼type Type = typeof schema.Type
console.log(Schema.decodeUnknownSync(schema)({ c: "c", b: 1 }))// Output: { a: 'c', b: 1 }
console.log(Schema.decodeUnknownSync(schema)({ b: 1 }))// Output: { b: 1 }Renaming Properties of an Existing Schema
For an existing struct, rename its decoded fields with mapFields, then use Schema.encodeKeys to preserve the original names in the encoded representation. For a union, apply the same operation to each member.
Example (Renaming Properties in a Struct Schema)
import { Schema, Struct } from "effect"
const Original = Schema.Struct({ c: Schema.String, b: Schema.Finite,})
// Renaming the "c" property to "a"////// ┌─── Struct<{// | readonly a: string;// | readonly b: number;// | }>// ▼const Renamed = Original.mapFields((fields) => ({ a: fields.c, ...Struct.omit(fields, ["c"]),})).pipe(Schema.encodeKeys({ a: "c" }))
console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", b: 1 }))// Output: { a: "c", b: 1 }Example (Renaming Properties in Union Schemas)
import { Schema } from "effect"
const Original = Schema.Union([ Schema.Struct({ a: Schema.String, b: Schema.Finite, }), Schema.Struct({ a: Schema.String, d: Schema.Boolean, }),])
// Use "c" for "a" in the encoded representation of every memberconst Renamed = Original.mapMembers( ([first, second]) => [ first.pipe(Schema.encodeKeys({ a: "c" })), second.pipe(Schema.encodeKeys({ a: "c" })), ] as const,)
console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", b: 1 }))// Output: { a: "c", b: 1 }
console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", d: false }))// Output: { a: "c", d: false }Recursive Schemas
The Schema.suspend function is designed for defining schemas that reference themselves, such as in recursive data structures.
Example (Self-Referencing Schema)
In this example, the Category schema references itself through the subcategories field, which is an array of Category objects.
import { Schema } from "effect"
interface Category { readonly name: string readonly subcategories: ReadonlyArray<Category>}
const Category = Schema.Struct({ name: Schema.String, subcategories: Schema.Array( Schema.suspend((): Schema.Codec<Category> => Category), ),})Example (Type Inference Error)
import { Schema } from "effect"
// @errors: 7022const Category = Schema.Struct({ name: Schema.String, // @errors: 7022 7024 subcategories: Schema.Array(Schema.suspend(() => Category)),})A Helpful Pattern to Simplify Schema Definition
As we’ve observed, it’s necessary to define an interface for the Type of the schema to enable recursive schema definition, which can complicate things and be quite tedious.
One pattern to mitigate this is to separate the field responsible for recursion from all other fields.
Example (Separating Recursive Fields)
import { Schema } from "effect"
const fields = { name: Schema.String, // ...other fields as needed}
// Define an interface for the Category schema,// extending the Type of the defined fieldsinterface Category extends Schema.Struct.Type<typeof fields> { // Define `subcategories` using recursion readonly subcategories: ReadonlyArray<Category>}
const Category = Schema.Struct({ ...fields, // Spread in the base fields subcategories: Schema.Array( // Define `subcategories` using recursion Schema.suspend((): Schema.Codec<Category> => Category), ),})Mutually Recursive Schemas
You can also use Schema.suspend to create mutually recursive schemas, where two schemas reference each other. In the following example, Expression and Operation form a simple arithmetic expression tree by referencing each other.
Example (Defining Mutually Recursive Schemas)
import { Schema } from "effect"
interface Expression { readonly type: "expression" readonly value: number | Operation}
interface Operation { readonly type: "operation" readonly operator: "+" | "-" readonly left: Expression readonly right: Expression}
const Expression = Schema.Struct({ type: Schema.Literal("expression"), value: Schema.Union([ Schema.Finite, Schema.suspend((): Schema.Codec<Operation> => Operation), ]),})
const Operation = Schema.Struct({ type: Schema.Literal("operation"), operator: Schema.Literals(["+", "-"]), left: Expression, right: Expression,})Recursive Types with Different Encoded and Type
Defining a recursive schema where the Encoded type differs from the Type type adds another layer of complexity. In such cases, we need to define two interfaces: one for the Type type, as seen previously, and another for the Encoded type.
Example (Recursive Schema with Different Encoded and Type Definitions)
Let’s consider an example with an id field defined by Schema.FiniteFromString.
Its Type is number, while its Encoded type is string.
When we add this field to the Category schema, TypeScript raises an error:
import { Schema } from "effect"
const fields = { id: Schema.FiniteFromString, name: Schema.String,}
interface Category extends Schema.Struct.Type<typeof fields> { readonly subcategories: ReadonlyArray<Category>}
const Category = Schema.Struct({ ...fields, subcategories: Schema.Array( // @errors: 2322 Schema.suspend((): Schema.Codec<Category> => Category), ),})This fails because Schema.Codec<Category> defaults the encoded type to Category. The recursive edge must also specify CategoryEncoded:
import { Schema } from "effect"
const fields = { id: Schema.FiniteFromString, name: Schema.String,}
interface Category extends Schema.Struct.Type<typeof fields> { readonly subcategories: ReadonlyArray<Category>}
interface CategoryEncoded extends Schema.Struct.Encoded<typeof fields> { readonly subcategories: ReadonlyArray<CategoryEncoded>}
const Category = Schema.Struct({ ...fields, subcategories: Schema.Array( Schema.suspend((): Schema.Codec<Category, CategoryEncoded> => Category), ),})