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.
A “title” should be concise, while a “description” provides a more detailed explanation of the purpose of the data described by the schema.
Example (Adding 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 (Missing 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 Annotations)
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
Let’s start with the simple definition of a property signature that can be used to add annotations:
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
Schema.optional(schema: Schema<A, I, R>) defines a basic optional property.
Decoding
<missing value> remains <missing value>
undefined remains undefined
Input i: I transforms to a: A
Encoding
<missing value> remains <missing value>
undefined remains undefined
Input a: A transforms back to i: I
Example
Optional with Nullability
Schema.optionalWith(schema: Schema<A, I, R>, { nullable: true }) allows handling of null values as equivalent to missing values.
Decoding
<missing value> remains <missing value>
undefined remains undefined
null transforms to <missing value>
Input i: I transforms to a: A
Encoding
<missing value> remains <missing value>
undefined remains undefined
Input a: A transforms back to i: I
Example
Optional with Exactness
Schema.optionalWith(schema: Schema<A, I, R>, { exact: true }) ensures that only the exact types specified are handled, excluding undefined.
Decoding
<missing value> remains <missing value>
Input i: I transforms to a: A
Encoding
<missing value> remains <missing value>
Input a: A transforms back to i: I
Example
Combining Nullability and Exactness
Schema.optionalWith(schema: Schema<A, I, R>, { exact: true, nullable: true }) combines handling for exact types and null values.
Decoding
<missing value> remains <missing value>
null transforms to <missing value>
Input i: I transforms to a: A
Encoding
<missing value> remains <missing value>
Input a: A transforms back to i: I
Example
Representing Optional Fields with never Type
When defining types in TypeScript that include optional fields with the type never, such as:
the approach varies based on the exactOptionalPropertyTypes configuration in your tsconfig.json
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.
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 most straightforward use case. If the input is missing or undefined, the default value will be used.
Syntax
Decoding: Translates missing or undefined inputs to a default value
Encoding: Input a: A transforms back to i: I
Example
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
Decoding: Applies the default value only if the input is missing
Encoding: Input a: A transforms back to i: I
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
Decoding: Treats null, undefined, or missing inputs as defaults
Encoding: Input a: A transforms back to i: I
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
Decoding: Defaults are applied when values are null or missing
Encoding: Input a: A transforms back to i: I
Optional Fields as Options
In many cases you may want to transform an optional field into an Option type.
This approach allows you to explicitly manage whether a field is present or not, rather than relying on undefined or null.
Basic Optional with Option Type
You can configure a schema to handle optional fields as Option types, where missing or undefined values are converted to Option.none() and present values are wrapped in Option.some().
Syntax
Decoding
Missing values or undefined are converted to Option.none()
Provided values (i: I) are converted to Option.some(a: A)
Encoding
Option.none() results in the value being omitted
Option.some(a: A) is converted back to the original input (i: I)
Example
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
Only truly missing values are converted to Option.none()
Provided values (i: I) are converted to Option.some(a)
Encoding
Option.none() results in the value being omitted
Option.some(a: A) is converted back to the original input (i: I)
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
Treats missing, undefined, and null values all as Option.none()
Provided values (i: I) are converted to Option.some(a: A)
Encoding
Option.none() results in the value being omitted
Option.some(a: A) is converted back to the original input (i: I)
Combining Exactness and Nullability
When both exact and nullable are used together, only null and missing fields trigger Option.none(), while undefined is treated as an invalid value.
Syntax
Decoding
Missing or null values lead to Option.none()
Provided values (i: I) are converted to Option.some(a: A)
Encoding
Option.none() results in the value being omitted
Option.some(a: A) is converted back to the original input (i: I)
Optional Fields Primitives
optionalToOptional
The Schema.optionalToOptional API is used to manage the transformation from an optional field to another optional field.
With this, we can control both the output type and the presence or absence of the field.
For example a common use case is to equate a specific value in the source field with the absence of value in the destination field.
Syntax
You can transform the type by specifying a schema for to, which can be different from the schema of from.
Additionally, you can control the presence or absence of the field using decode and encode, with the following meanings:
Option.none() as an argument means the value is missing in the input
Option.none() as a return value means the value will be missing in the output
Example
Suppose we have an optional field of type string, and we want to exclude empty strings from the output. In other words, if the input contains an empty string, we want the field to be absent in the output.
optionalToRequired
The Schema.optionalToRequired API allows us to transform an optional field into a required one, applying custom logic if the field is absent in the input.
Syntax
You can control the presence or absence of the field using decode and encode, with the following meanings:
Option.none() as an argument means the value is missing in the input
Option.none() as a return value means the value will be missing in the output
Example
For instance, a common use case is to assign a default value to the field in the output if it’s missing in the input.
requiredToOptional
This API allows developers to specify how a field that is normally required can be treated as optional based on custom logic.
Syntax
You can control the presence or absence of the field using decode and encode, with the following meanings:
Option.none() as an argument means the value is missing in the input
Option.none() as a return value means the value will be missing in the output
Example
Let’s look at a practical example where a field name that is typically required can be considered optional if it’s an empty string during decoding, and ensure there is always a value during encoding by providing a default.
Extending Schemas
Spreading Struct fields
Structs expose their fields through a fields property. This feature can be utilized to extend an existing struct with additional fields or to merge fields from another struct.
Example (Adding Fields)
Example (Integrating Additional Index Signatures)
Example (Merging Fields from Two Structs)
The extend function
The Schema.extend function offers a structured way to extend schemas, particularly useful when direct field spreading is insufficient—for instance, when you need to extend a struct with a union of structs.
Possible 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)
This example shows an attempt to extend a struct with another struct where field names overlap, leading to an error:
Example (Extending a refinement of Schema.String with another refinement)
Renaming Properties
Renaming a Property During Definition
To rename a property directly during schema creation, you can utilize the Schema.fromKey function. This function is particularly useful when you want to map properties from the input object to different names in the resulting schema object.
Example (Renaming a Required Property)
Example (Renaming an Optional Property)
Note that Schema.optional returns a PropertySignature, which simplifies the process by eliminating the need for explicit Schema.propertySignature usage as required in the previous example.
Renaming Properties of an Existing Schema
For existing schemas, the rename API offers a way to systematically change property names across a schema, even within complex structures like unions.
Example (Renaming Properties in a Struct Schema)
Example (Renaming Properties in Union Schemas)
Recursive Schemas
The Schema.suspend function is useful when you need to define a schema that depends on itself, like in the case of recursive data structures.
Example
In this example, the Category schema depends on itself because it has a field subcategories that is an array of Category objects.
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.
Mutually Recursive Schemas
Here’s an example of two mutually recursive schemas, Expression and Operation, that represent a simple arithmetic expression tree.
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
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: