Getting Started
You can import the necessary types and functions from the effect/Schema
module:
Example (Namespace Import)
Example (Named Import)
One common way to define a Schema
is by utilizing the Struct
constructor.
This constructor allows you to create a new schema that outlines an object with specific properties.
Each property in the object is defined by its own schema, which specifies the data type and any validation rules.
Example (Defining a Simple Object Schema)
This Person
schema describes an object with a name
(string) and age
(number) property:
Once you’ve defined a schema (Schema<Type, Encoded, Context>
), you can extract the inferred type Type
in two ways:
- Using the
Schema.Type
utility - Accessing the
Type
field directly on your schema
Example (Extracting Inferred Type)
The resulting type will look like this:
Alternatively, you can extract the Person
type using the interface
keyword, which may improve readability and performance in some cases.
Example (Extracting Type with an Interface)
Both approaches yield the same result, but using an interface provides benefits such as performance advantages and improved readability.
In a Schema<Type, Encoded, Context>
, the Encoded
type can differ from the Type
type, representing the format in which data is encoded. You can extract the Encoded
type in two ways:
- Using the
Schema.Encoded
utility - Accessing the
Encoded
field directly on the schema
Example (Extracting the Encoded Type)
The resulting type is:
Note that age
is of type string
in the Encoded
type of the schema and is of type number
in the Type
type of the schema.
Alternatively, you can define the PersonEncoded
type using the interface
keyword, which can enhance readability and performance.
Example (Extracting Encoded Type with an Interface)
Both approaches yield the same result, but using an interface provides benefits such as performance advantages and improved readability.
In a Schema<Type, Encoded, Context>
, the Context
type represents any external data or dependencies that the schema requires to perform encoding or decoding. You can extract the inferred Context
type in two ways:
- Using the
Schema.Context
utility. - Accessing the
Context
field on the schema.
Example (Extracting the Context Type)
When defining a schema, you may want to create a schema with an opaque type. This is useful when you want to hide the internal structure of the schema and only expose the type of the schema.
Example (Creating an Opaque Schema)
To create a schema with an opaque type, you can use the following technique that re-declares the schema:
Alternatively, you can use the Class APIs (see the Class APIs section for more details).
Note that the technique shown above becomes more complex when the schema is defined such that Type
is different from Encoded
.
Example (Opaque Schema with Different Type and Encoded)
In this case, the field "age"
is of type string
in the Encoded
type of the schema and is of type number
in the Type
type of the schema. Therefore, we need to define two interfaces (PersonEncoded
and Person
) and use both to redeclare our final schema Person
.
It’s important to note that by default, most constructors exported by
effect/Schema
return readonly
types.
Example (Readonly Types in a Schema)
For instance, in the Person
schema below:
the resulting inferred Type
would be:
When working with unknown data types in TypeScript, decoding them into a known structure can be challenging. Luckily, effect/Schema
provides several functions to help with this process. Let’s explore how to decode unknown values using these functions.
API | Description |
---|---|
decodeUnknownSync | Synchronously decodes a value and throws an error if parsing fails. |
decodeUnknownOption | Decodes a value and returns an Option type. |
decodeUnknownEither | Decodes a value and returns an Either type. |
decodeUnknownPromise | Decodes a value and returns a Promise . |
decodeUnknown | Decodes a value and returns an Effect. |
The Schema.decodeUnknownSync
function is useful when you want to parse a value and immediately throw an error if the parsing fails.
Example (Using decodeUnknownSync
for Immediate Decoding)
The Schema.decodeUnknownEither
function allows you to parse a value and receive the result as an Either, representing success (Right
) or failure (Left
). This approach lets you handle parsing errors more gracefully without throwing exceptions.
Example (Using Schema.decodeUnknownEither
for Error Handling)
If your schema involves asynchronous transformations, the Schema.decodeUnknownSync
and Schema.decodeUnknownEither
functions will not be suitable.
In such cases, you should use the Schema.decodeUnknown
function, which returns an Effect.
Example (Handling Asynchronous Decoding)
In the code above, the first approach using Schema.decodeUnknownEither
results in an error indicating that the transformation cannot be resolved synchronously.
This occurs because Schema.decodeUnknownEither
is not designed for async operations.
The second approach, which uses Schema.decodeUnknown
, works correctly, allowing you to handle asynchronous transformations and return the expected result.
The Schema
module provides several encode*
functions to encode data according to a schema:
API | Description |
---|---|
encodeSync | Synchronously encodes data and throws an error if encoding fails. |
encodeOption | Encodes data and returns an Option type. |
encodeEither | Encodes data and returns an Either type representing success or failure. |
encodePromise | Encodes data and returns a Promise . |
encode | Encodes data and returns an Effect. |
Example (Using Schema.encodeSync
for Immediate Encoding)
Note that during encoding, the number value 30
was converted to a string "30"
.
In certain cases, it may not be feasible to support encoding for a schema. While it is generally advised to define schemas that allow both decoding and encoding, there are situations where encoding a particular type is either unsupported or unnecessary. In these instances, the Forbidden
issue can signal that encoding is not available for certain values.
Example (Using Forbidden
to Indicate Unsupported Encoding)
Here is an example of a transformation that never fails during decoding. It returns an Either containing either the decoded value or the original input. For encoding, it is reasonable to not support it and use Forbidden
as the result.
Explanation
- Decoding: The
SafeDecode
function ensures that decoding never fails. It wraps the decoded value in an Either, where a successful decoding results in aRight
and a failed decoding results in aLeft
containing the original input. - Encoding: The encoding process uses the
Forbidden
error to indicate that encoding aLeft
value is not supported. OnlyRight
values are successfully encoded.
The Schema.decodeUnknownEither
and Schema.encodeEither
functions returns a Either:
where ParseError
is defined as follows (simplified):
In this structure, 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<Type, ParseError>
contains the inferred data type described by the schema (Type
).
A successful parse yields a Right
value with the parsed data Type
, while a failed parse results in a Left
value containing a ParseError
.
The options below provide control over both decoding and encoding behaviors.
By default, any properties not defined in the schema are removed from the output when parsing a value. This ensures the parsed data conforms strictly to the expected structure.
If you want to detect and handle unexpected properties, use the onExcessProperty
option (default value: "ignore"
), which allows you to raise an error for excess properties. This can be helpful when you need to validate and catch unanticipated properties.
Example (Setting onExcessProperty
to "error"
)
To retain extra properties, set onExcessProperty
to "preserve"
.
Example (Setting onExcessProperty
to "preserve"
)
The errors
option enables you to retrieve all errors encountered during parsing. By default, only the first error is returned. Setting errors
to "all"
provides comprehensive error feedback, which can be useful for debugging or offering detailed validation feedback.
Example (Setting errors
to "all"
)
The propertyOrder
option provides control over the order of object fields in the output. This feature is particularly useful when the sequence of keys is important for the consuming processes or when maintaining the input order enhances readability and usability.
By default, the propertyOrder
option is set to "none"
. This means that the internal system decides the order of keys to optimize parsing speed.
The order of keys in this mode should not be considered stable, and it’s recommended not to rely on key ordering as it may change in future updates.
Setting propertyOrder
to "original"
ensures that the keys are ordered as they appear in the input during the decoding/encoding process.
Example (Synchronous Decoding)
Example (Asynchronous Decoding)
The parseOptions
annotation allows you to customize parsing behavior at different schema levels, enabling you to apply unique parsing settings to nested schemas within a structure. Options defined within a schema override parent-level settings and apply to all nested schemas.
Example (Using parseOptions
to Customize Error Handling)
Detailed Output Explanation:
In this example:
- The main schema is configured to display all errors. Hence, you will see errors related to both the
d
field (since it’s missing) and any errors from thea
subschema. - The subschema (
a
) is set to display only the first error. Although bothb
andc
fields are missing, only the first missing field (b
) is reported.
The Schema.is
function provides a way to verify if a value conforms to a given schema. It acts as a type guard, taking a value of type unknown
and determining if it matches the structure and type constraints defined in the schema.
Here’s how the Schema.is
function works:
-
Schema Definition: Define a schema to describe the structure and constraints of the data type you expect. For instance,
Schema<Type, Encoded, Context>
, whereType
is the target type you want to validate against. -
Type Guard Creation: Use the schema to create a user-defined type guard,
(u: unknown) => u is Type
. This function can be used at runtime to check if a value meets the requirements of the schema.
Example (Creating and Using a Type Guard)
The generated isPerson
function has the following signature:
While type guards verify whether a value conforms to a specific type, the Schema.asserts
function goes further by asserting that an input matches the schema type Type
(from Schema<Type, Encoded, Context>
).
If the input does not match the schema, it throws a detailed error, making it useful for runtime validation.
Example (Creating and Using an Assertion)
The assertsPerson
function generated from the schema has the following signature:
When decoding, it’s important to understand how missing properties are processed. By default, if a property is not present in the input, it is treated as if it were present with an undefined
value.
Example (Default Behavior of Missing Properties)
In this example, although the key "a"
is not present in the input, it is treated as { a: undefined }
by default.
If you need your validation logic to differentiate between genuinely missing properties and those explicitly set to undefined
, you can enable the exact
option.
Example (Setting exact: true
to Distinguish Missing Properties)
For the APIs Schema.is
and Schema.asserts
, however, the default behavior is to treat missing properties strictly, where the default for exact
is true
:
Example (Strict Handling of Missing Properties with Schema.is
and Schema.asserts
)
The naming conventions in effect/Schema
are designed to be straightforward and logical, focusing primarily on compatibility with JSON serialization. This approach simplifies the understanding and use of schemas, especially for developers who are integrating web technologies where JSON is a standard data interchange format.
JSON-Compatible Types
Schemas that naturally serialize to JSON-compatible formats are named directly after their data types.
For instance:
Schema.Date
: serializes JavaScript Date objects to ISO-formatted strings, a typical method for representing dates in JSON.Schema.Number
: used directly as it maps precisely to the JSON number type, requiring no special transformation to remain JSON-compatible.
Non-JSON-Compatible Types
When dealing with types that do not have a direct representation in JSON, the naming strategy incorporates additional details to indicate the necessary transformation. This helps in setting clear expectations about the schema’s behavior:
For instance:
Schema.DateFromSelf
: indicates that the schema handlesDate
objects, which are not natively JSON-serializable.Schema.NumberFromString
: this naming suggests that the schema processes numbers that are initially represented as strings, emphasizing the transformation from string to number when decoding.
The primary goal of these schemas is to ensure that domain objects can be easily serialized (“encoded”) and deserialized (“decoded”) for transmission over network connections, thus facilitating their transfer between different parts of an application or across different applications.
While JSON’s ubiquity justifies its primary consideration in naming, the conventions also accommodate serialization for other types of transport. For instance, converting a Date
to a string is a universally useful method for various communication protocols, not just JSON. Thus, the selected naming conventions serve as sensible defaults that prioritize clarity and ease of use, facilitating the serialization and deserialization processes across diverse technological environments.