To declare a schema for a primitive data type, such as File, you can use the Schema.declare function along with a type guard.
Example (Declaring a Schema for File)
To enhance the default error message, you can add annotations, particularly the identifier, title, and description annotations (none of these annotations are required, but they are encouraged for good practice and can make your schema “self-documenting”). These annotations will be utilized by the messaging system to return more meaningful messages.
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)
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<A>)
Adding Compilers Annotations
When defining a new data type, some compilers like Arbitrary or Pretty may not know how to handle the new type.
This can result in an error, as the compiler may lack the necessary information for generating instances or producing readable output:
Example (Attempting to Generate Arbitrary Values Without Required Annotations)
In the above example, attempting to generate arbitrary values for the FileFromSelf schema fails because the compiler lacks necessary annotations. To resolve this, you need to provide annotations for generating arbitrary data:
Example (Adding Arbitrary Annotation for Custom File Schema)
For more details on how to add annotations for the Arbitrary compiler, 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)
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 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)
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 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)
Note that you can use unique symbols as brands to ensure uniqueness across modules / packages.
Example (Using a unique symbol as a Brand)
Reusing 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)
Utilizing Default Constructors
The Schema.brand function includes a default constructor to facilitate the creation of branded values.
Property Signatures
A PropertySignature represents a transformation from a “From” field to a “To” field. This allows you to define mappings between incoming data fields and your internal model.
Basic Usage
A property signature can be defined with annotations to provide additional context about a field.
Example (Adding Annotations to a Property Signature)
A PropertySignature type contains several parameters, each providing details about the transformation between the source field (From) and the target field (To). Let’s take a look at what each of these parameters represents:
Parameter
Description
age
Key of the “To” field
ToToken
Indicates field requirement: "?:" for optional, ":" for required
ToType
Type of the “To” field
FromKey
(Optional, default = never) Indicates the source field key, typically the same as “To” field key unless specified
FormToken
Indicates source field requirement: "?:" for optional, ":" for required
FromType
Type of the “From” field
HasDefault
Indicates if there is a constructor default value (Boolean)
In the example above, the PropertySignature type for age is:
This means:
Parameter
Description
age
Key of the “To” field
ToToken
":" indicates that the age field is required
ToType
Type of the age field is number
FromKey
never indicates that the decoding occurs from the same field named age
FormToken
":" indicates that the decoding occurs from a required age field
FromType
Type of the “From” field is string
HasDefault
false: indicates there is no default value
Sometimes, the source field (the “From” field) may have a different name from the field in your internal model. You can map between these fields using the Schema.fromKey function.
Example (Mapping from a Different Key)
When you map from "AGE" to "age", the PropertySignature type changes to:
Optional Fields
Basic Optional Property
The syntax:
creates an optional property within a schema, allowing fields to be omitted or set to undefined.
Decoding
Input
Output
<missing value>
remains <missing value>
undefined
remains undefined
i: I
transforms to a: A
Encoding
Input
Output
<missing value>
remains <missing value>
undefined
remains undefined
a: A
transforms back to i: I
Example (Defining an Optional Number Field)
Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
Optional with Nullability
The syntax:
creates an optional property within a schema, treating null values the same as missing values.
Decoding
Input
Output
<missing value>
remains <missing value>
undefined
remains undefined
null
transforms to <missing value>
i: I
transforms to a: A
Encoding
Input
Output
<missing value>
remains <missing value>
undefined
remains undefined
a: A
transforms back to i: I
Example (Handling Null as Missing Value)
Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
Optional with Exactness
The syntax:
creates an optional property while enforcing strict typing. This means that only the specified type (excluding undefined) is accepted. Any attempt to decode undefined results in an error.
Decoding
Input
Output
<missing value>
remains <missing value>
undefined
ParseError
i: I
transforms to a: A
Encoding
Input
Output
<missing value>
remains <missing value>
a: A
transforms back to i: I
Example (Using Exactness with Optional Field)
Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
Combining Nullability and Exactness
The syntax:
allows you to define an optional property that enforces strict typing (exact type only) while also treating null as equivalent to a missing value.
Decoding
Input
Output
<missing value>
remains <missing value>
null
transforms to <missing value>
undefined
ParseError
i: I
transforms to a: A
Encoding
Input
Output
<missing value>
remains <missing value>
a: A
transforms back to i: I
Example (Using Exactness and Handling Null as Missing Value with Optional Field)
Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
Representing Optional Fields with never Type
When creating a schema to replicate a TypeScript type that includes optional fields with the never type, like:
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.
Example (exactOptionalPropertyTypes: true)
When this feature is turned on, the Schema.optionalWith function is recommended.
It ensures stricter enforcement of the field’s absence.
Default Values
The default option in Schema.optionalWith allows you to set default values that are applied during both decoding and object construction phases.
This feature ensures that even if certain properties are not provided by the user, the system will automatically use the specified default values.
The Schema.optionalWith function offers several ways to control how defaults are applied during decoding and encoding. You can fine-tune whether defaults are applied only when the input is completely missing, or even when null or undefined values are provided.
Basic Default
This is the simplest use case. If the input is missing or undefined, the default value will be applied.
Syntax
Operation
Behavior
Decoding
Applies the default value if the input is missing or undefined
Encoding
Transforms the input a: A back to i: I
Example (Applying Default When Field Is Missing or undefined)
Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
Default with Exactness
When you want the default value to be applied only if the field is completely missing (not when it’s undefined), you can use the exact option.
Syntax
Operation
Behavior
Decoding
Applies the default value only if the input is missing
Encoding
Transforms the input a: A back to i: I
Example (Applying Default Only When Field Is Missing)
Default with Nullability
In cases where you want null values to trigger the default behavior, you can use the nullable option. This ensures that if a field is set to null, it will be replaced by the default value.
Syntax
Operation
Behavior
Decoding
Applies the default value if the input is missing or undefined or null
Encoding
Transforms the input a: A back to i: I
Example (Applying Default When Field Is Missing or undefined or null)
Combining Exactness and Nullability
For a more strict approach, you can combine both exact and nullable options. This way, the default value is applied only when the field is null or missing, and not when it’s explicitly set to undefined.
Syntax
Operation
Behavior
Decoding
Applies the default value if the input is missing or null
Encoding
Transforms the input a: A back to i: I
Example (Applying Default Only When Field Is Missing or null)
Optional Fields as Options
When working with optional fields, you may want to handle them as Option types. 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
You can configure a schema to treat optional fields as Option types, where missing or undefined values are converted to Option.none() and existing values are wrapped in Option.some().
Syntax
Decoding
Input
Output
<missing value>
transforms to Option.none()
undefined
transforms to Option.none()
i: I
transforms to Option.some(a: A)
Encoding
Input
Output
Option.none()
transforms to <missing value>
Option.some(a: A)
transforms back to i: I
Example (Handling Optional Field as Option)
Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
Optional with Exactness
The exact option ensures that the default behavior of the optional field applies only when the field is entirely missing, not when it is undefined.
Syntax
Decoding
Input
Output
<missing value>
transforms to Option.none()
undefined
ParseError
i: I
transforms to Option.some(a: A)
Encoding
Input
Output
Option.none()
transforms to <missing value>
Option.some(a: A)
transforms back to i: I
Example (Using Exactness with Optional Field as Option)
Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
Optional with Nullability
The nullable option extends the default behavior to treat null as equivalent to Option.none(), alongside missing or undefined values.
Syntax
Decoding
Input
Output
<missing value>
transforms to Option.none()
undefined
transforms to Option.none()
null
transforms to Option.none()
i: I
transforms to Option.some(a: A)
Encoding
Input
Output
Option.none()
transforms to <missing value>
Option.some(a: A)
transforms back to i: I
Example (Handling Null as Missing Value with Optional Field as Option)
Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
Combining Exactness and Nullability
When both exact and nullable options are used together, only null and missing fields are treated as Option.none(), while undefined is considered an invalid value.
Syntax
Decoding
Input
Output
<missing value>
transforms to Option.none()
undefined
ParseError
null
transforms to Option.none()
i: I
transforms to Option.some(a: A)
Encoding
Input
Output
Option.none()
transforms to <missing value>
Option.some(a: A)
transforms back to i: I
Example (Using Exactness and Handling Null as Missing Value with Optional Field as Option)
Exposed Values
You can access the original schema type (before it was marked as optional) using the from property.
Example (Accessing the Original Schema)
Optional Fields Primitives
optionalToOptional
The Schema.optionalToOptional API allows you to manage transformations from an optional field in the input to an optional field in the output. This can be useful for controlling both the output type and whether a field is present or absent based on specific criteria.
One common use case for optionalToOptional is handling fields where a specific input value, such as an empty string, should be treated as an absent field in the output.
Syntax
In this function:
The from parameter specifies the input schema, and to specifies the output schema.
The decode and encode functions define how the field should be interpreted on both sides:
Option.none() as an input argument indicates a missing field in the input.
Returning Option.none() from either function will omit the field in the output.
Example (Omitting Empty Strings from Output)
Consider an optional field of type string where empty strings in the input should be removed from the output.
optionalToRequired
The Schema.optionalToRequired API lets you transform an optional field into a required one, with custom logic to handle cases when the field is missing in the input.
Syntax
In this function:
from specifies the input schema, while to specifies the output schema.
The decode and encode functions define the transformation behavior:
Passing Option.none() to decode means the field is absent in the input. The function can then return a default value for the output.
Returning Option.none() in encode will omit the field in the output.
Example (Setting a Default Value for a Missing Field)
In this example, if the input object lacks the maybe field, we will provide a default value for it in the output.
requiredToOptional
The requiredToOptional API allows you to transform a required field into an optional one, applying custom logic to determine when the field can be omitted.
Syntax
With decode and encode functions, you control the presence or absence of the field:
Option.none() as an argument in decode means the field is missing in the input.
Option.none() as a return value from encode means the field will be omitted in the output.
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).
Extending Schemas
Schemas in effect can be extended in multiple ways, allowing you to combine or enhance existing types with additional fields or functionality. One common method is to use the fields property available in Struct schemas. This property provides a convenient way to add fields or merge fields from different structs while retaining the original Struct type. This approach also makes it easier to access and modify fields.
For more complex cases, such as extending a struct with a union, you may want to use the Schema.extend function, which offers flexibility in scenarios where direct field spreading may not be sufficient.
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)
Example (Adding Additional Index Signatures)
Example (Combining Fields from Multiple Structs)
The extend function
The Schema.extend function provides a structured method to expand schemas, especially useful when direct field spreading isn’t sufficient—such as when you need to extend a struct with a union of other structs.
Supported extensions include:
Schema.String with another Schema.String refinement or a string literal
Schema.Number with another Schema.Number refinement or a number literal
Schema.Boolean with another Schema.Boolean refinement or a boolean literal
A struct with another struct where overlapping fields support extension
A struct with in index signature
A struct with a union of supported schemas
A refinement of a struct with a supported schema
A suspend of a struct with a supported schema
Example (Extending a Struct with a Union of Structs)
Example (Attempting to Extend Structs with Conflicting Fields)
This example demonstrates an attempt to extend a struct with another struct that contains overlapping field names, resulting in an error due to conflicting types.
Example (xtending a Refinement with Another Refinement)
In this example, we extend two refinements, Integer and Positive, creating a schema that enforces both integer and positivity constraints.
Renaming Properties
Renaming a Property During Definition
To rename a property directly during schema creation, you can utilize the Schema.fromKey function.
Example (Renaming a Required Property)
Example (Renaming an Optional Property)
Using Schema.optional automatically returns a PropertySignature, making it unnecessary to explicitly use Schema.propertySignature as required for renaming required fields in the previous example.
Renaming Properties of an Existing Schema
For existing schemas, the Schema.rename API offers a way to systematically change property names across a schema, even within complex structures like unions, though in case of structs you lose the original field types.
Example (Renaming Properties in a Struct Schema)
Example (Renaming Properties in Union Schemas)
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.
Example (Type Inference Error)
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)
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)
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: suppose we want to add an id field to the Category schema, where the schema for id is NumberFromString.
It’s important to note that NumberFromString is a schema that transforms a string into a number, so the Type and Encoded types of NumberFromString differ, being number and string respectively.
When we add this field to the Category schema, TypeScript raises an error:
This error occurs because the explicit annotation Schema.Schema<Category> is no longer sufficient and needs to be adjusted by explicitly adding the Encoded type: