Skip to content

Schema 0.69 (Release)

Several features were introduced in version 0.68 through various patches, here’s a recap of the most important updates in case you missed them.

The behavior of JSON Schema generation has been refined to enhance the handling of optional fields. Previously, schemas containing undefined could lead to exceptions; now, they are treated as optional automatically.

Before Update:

1
import { JSONSchema, Schema } from "@effect/schema"
2
3
const schema = Schema.Struct({
4
a: Schema.NullishOr(Schema.Number)
5
})
6
7
const jsonSchema = JSONSchema.make(schema)
8
console.log(JSON.stringify(jsonSchema, null, 2))
9
/*
10
throws
11
Error: Missing annotation
12
at path: ["a"]
13
details: Generating a JSON Schema for this schema requires a "jsonSchema" annotation
14
schema (UndefinedKeyword): undefined
15
*/

After Update:

1
import { JSONSchema, Schema } from "@effect/schema"
2
3
const schema = Schema.Struct({
4
a: Schema.NullishOr(Schema.Number)
5
})
6
7
const jsonSchema = JSONSchema.make(schema)
8
console.log(JSON.stringify(jsonSchema, null, 2))
9
/*
10
{
11
"$schema": "http://json-schema.org/draft-07/schema#",
12
"type": "object",
13
"required": [], // <=== empty
14
"properties": {
15
"a": {
16
"anyOf": [
17
{
18
"type": "number"
19
},
20
{
21
"$ref": "#/$defs/null"
22
}
23
]
24
}
25
},
26
"additionalProperties": false,
27
"$defs": {
28
"null": {
29
"const": null
30
}
31
}
32
}
33
*/

The generation of JSON schemas from records that utilize refinements has been improved to ensure error-free outputs.

Before Update:

1
import { JSONSchema, Schema } from "@effect/schema"
2
3
const schema = Schema.Record({
4
key: Schema.String.pipe(Schema.minLength(1)),
5
value: Schema.Number
6
})
7
8
console.log(JSONSchema.make(schema))
9
/*
10
throws
11
Error: Unsupported index signature parameter
12
schema (Refinement): a string at least 1 character(s) long
13
*/

After Update:

1
import { JSONSchema, Schema } from "@effect/schema"
2
3
const schema = Schema.Record({
4
key: Schema.String.pipe(Schema.minLength(1)),
5
value: Schema.Number
6
})
7
8
console.log(JSONSchema.make(schema))
9
/*
10
Output:
11
{
12
'$schema': 'http://json-schema.org/draft-07/schema#',
13
type: 'object',
14
required: [],
15
properties: {},
16
patternProperties: { '': { type: 'number' } },
17
propertyNames: {
18
type: 'string',
19
description: 'a string at least 1 character(s) long',
20
minLength: 1
21
}
22
}
23
*/

Resolved an issue where JSONSchema.make improperly generated JSON Schemas for schemas defined with S.parseJson(<real schema>). Previously, invoking JSONSchema.make on these transformed schemas produced a JSON Schema corresponding to a string type rather than the underlying real schema.

Before Update:

1
import { JSONSchema, Schema } from "@effect/schema"
2
3
// Define a schema that parses a JSON string into a structured object
4
const schema = Schema.parseJson(
5
Schema.Struct({
6
a: Schema.parseJson(Schema.NumberFromString) // Nested parsing from JSON string to number
7
})
8
)
9
10
console.log(JSONSchema.make(schema))
11
/*
12
{
13
'$schema': 'http://json-schema.org/draft-07/schema#',
14
'$ref': '#/$defs/JsonString',
15
'$defs': {
16
JsonString: {
17
type: 'string',
18
description: 'a JSON string',
19
title: 'JsonString'
20
}
21
}
22
}
23
*/

After Update:

1
import { JSONSchema, Schema } from "@effect/schema"
2
3
// Define a schema that parses a JSON string into a structured object
4
const schema = Schema.parseJson(
5
Schema.Struct({
6
a: Schema.parseJson(Schema.NumberFromString) // Nested parsing from JSON string to number
7
})
8
)
9
10
console.log(JSONSchema.make(schema))
11
/*
12
{
13
'$schema': 'http://json-schema.org/draft-07/schema#',
14
type: 'object',
15
required: [ 'a' ],
16
properties: { a: { type: 'string', description: 'a string', title: 'string' } },
17
additionalProperties: false
18
}
19
*/

We have introduced new transformations and filters to enhance string processing capabilities:

  • Transformations: Capitalize, Uncapitalize
  • Filters: Capitalized, Uncapitalized

The filterEffect function enhances the filter functionality by allowing the integration of effects, thus enabling asynchronous or dynamic validation scenarios. This is particularly useful when validations need to perform operations that require side effects, such as network requests or database queries.

Example: Validating Usernames Asynchronously

1
import { Schema } from "@effect/schema"
2
import { Effect } from "effect"
3
4
async function validateUsername(username: string) {
5
return Promise.resolve(username === "gcanti")
6
}
7
8
const ValidUsername = Schema.String.pipe(
9
Schema.filterEffect((username) =>
10
Effect.promise(() =>
11
validateUsername(username).then(
12
(valid) => valid || "Invalid username"
13
)
14
)
15
)
16
).annotations({ identifier: "ValidUsername" })
17
18
Effect.runPromise(Schema.decodeUnknown(ValidUsername)("xxx")).then(
19
console.log
20
)
21
/*
22
ParseError: ValidUsername
23
└─ Transformation process failure
24
└─ Invalid username
25
*/

These new functionalities provide efficient transformations between records and maps, supporting both encoding and decoding processes.

  • decoding
    • { readonly [x: string]: VI } -> ReadonlyMap<KA, VA>
  • encoding
    • ReadonlyMap<KA, VA> -> { readonly [x: string]: VI }

Example:

1
import { Schema } from "@effect/schema"
2
3
const schema = Schema.ReadonlyMapFromRecord({
4
key: Schema.BigInt,
5
value: Schema.NumberFromString
6
})
7
8
const decode = Schema.decodeUnknownSync(schema)
9
const encode = Schema.encodeSync(schema)
10
11
console.log(
12
decode({
13
"1": "4",
14
"2": "5",
15
"3": "6"
16
})
17
) // Map(3) { 1n => 4, 2n => 5, 3n => 6 }
18
console.log(
19
encode(
20
new Map([
21
[1n, 4],
22
[2n, 5],
23
[3n, 6]
24
])
25
)
26
) // { '1': '4', '2': '5', '3': '6' }

The extend function now supports combinations with Union, Suspend, and Refinement, broadening its applicability and flexibility.

These operations allow selective inclusion or exclusion of properties from structs, providing more control over schema composition.

Using pick:

The pick static function available in each struct schema can be used to create a new Struct by selecting particular properties from an existing Struct.

1
import { Schema } from "@effect/schema"
2
3
const MyStruct = Schema.Struct({
4
a: Schema.String,
5
b: Schema.Number,
6
c: Schema.Boolean
7
})
8
9
// Schema.Struct<{ a: typeof Schema.String; c: typeof Schema.Boolean; }>
10
const PickedSchema = MyStruct.pick("a", "c")

Using omit:

The omit static function available in each struct schema can be used to create a new Struct by excluding particular properties from an existing Struct.

1
import { Schema } from "@effect/schema"
2
3
const MyStruct = Schema.Struct({
4
a: Schema.String,
5
b: Schema.Number,
6
c: Schema.Boolean
7
})
8
9
// Schema.Struct<{ a: typeof Schema.String; c: typeof Schema.Boolean; }>
10
const PickedSchema = MyStruct.omit("b")

The introduction of a make constructor simplifies class instantiation, avoiding direct use of the new keyword and enhancing usability.

Example

1
import { Schema } from "@effect/schema"
2
3
class MyClass extends Schema.Class<MyClass>("MyClass")({
4
someField: Schema.String
5
}) {
6
someMethod() {
7
return this.someField + "bar"
8
}
9
}
10
11
// Create an instance of MyClass using the make constructor
12
const instance = MyClass.make({ someField: "foo" }) // same as new MyClass({ someField: "foo" })
13
14
// Outputs to console to demonstrate that the instance is correctly created
15
console.log(instance instanceof MyClass) // true
16
console.log(instance.someField) // "foo"
17
console.log(instance.someMethod()) // "foobar"

This update allows setting specific parse options at the schema level (using the parseOptions annotation), ensuring precise control over parsing behaviors throughout your schemas.

Example Configuration:

1
import { Schema } from "@effect/schema"
2
import { Either } from "effect"
3
4
const schema = Schema.Struct({
5
a: Schema.Struct({
6
b: Schema.String,
7
c: Schema.String
8
}).annotations({
9
title: "first error only",
10
parseOptions: { errors: "first" } // Only the first error in this sub-schema is reported
11
}),
12
d: Schema.String
13
}).annotations({
14
title: "all errors",
15
parseOptions: { errors: "all" } // All errors in the main schema are reported
16
})
17
18
const result = Schema.decodeUnknownEither(schema)(
19
{ a: {} },
20
{ errors: "first" }
21
)
22
if (Either.isLeft(result)) {
23
console.log(result.left.message)
24
}
25
/*
26
all errors
27
├─ ["d"]
28
│ └─ is missing
29
└─ ["a"]
30
└─ first error only
31
└─ ["b"]
32
└─ is missing
33
*/

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 the a subschema.
  • The subschema (a) is set to display only the first error. Although both b and c fields are missing, only the first missing field (b) is reported.

For some of the breaking changes, a code-mod has been released to make migration as easy as possible.

You can run it by executing:

Terminal window
1
npx @effect/codemod schema-0.69 src/**/*

It might not be perfect - if you encounter issues, let us know! Also make sure you commit any changes before running it, in case you need to revert anything.

We’ve improved the TaggedRequest API to make it more intuitive by grouping parameters into a single object:

Before Update:

1
class Sample extends Schema.TaggedRequest<Sample>()(
2
"Sample",
3
Schema.String, // Failure Schema
4
Schema.Number, // Success Schema
5
{ id: Schema.String, foo: Schema.Number } // Payload Schema
6
) {}

After Update:

1
class Sample extends Schema.TaggedRequest<Sample>()("Sample", {
2
payload: {
3
id: Schema.String,
4
foo: Schema.Number
5
},
6
success: Schema.Number,
7
failure: Schema.String
8
}) {}

The Record constructor now consistently accepts an object argument, aligning it with similar constructors such as Map and HashMap:

Before Update:

1
import { Schema } from "@effect/schema"
2
3
const schema = Schema.Record(Schema.String, Schema.Number)

After Update:

1
import { Schema } from "@effect/schema"
2
3
const schema = Schema.Record({ key: Schema.String, value: Schema.Number })

Support for extending Schema.String, Schema.Number, and Schema.Boolean with refinements has been added:

1
import { Schema } from "@effect/schema"
2
3
const Integer = Schema.Int.pipe(Schema.brand("Int"))
4
const Positive = Schema.Positive.pipe(Schema.brand("Positive"))
5
6
// Schema.Schema<number & Brand<"Positive"> & Brand<"Int">, number, never>
7
const PositiveInteger = Schema.asSchema(Schema.extend(Positive, Integer))
8
9
Schema.decodeUnknownSync(PositiveInteger)(-1)
10
/*
11
throws
12
ParseError: Int & Brand<"Int">
13
└─ From side refinement failure
14
└─ Positive & Brand<"Positive">
15
└─ Predicate refinement failure
16
└─ Expected Positive & Brand<"Positive">, actual -1
17
*/
18
19
Schema.decodeUnknownSync(PositiveInteger)(1.1)
20
/*
21
throws
22
ParseError: Int & Brand<"Int">
23
└─ Predicate refinement failure
24
└─ Expected Int & Brand<"Int">, actual 1.1
25
*/

To improve clarity, we have renamed nonEmpty filter to nonEmptyString and NonEmpty schema to NonEmptyString.

We’ve refined the optional and partial APIs by splitting them into two distinct methods: one without options (optional and partial) and another with options (optionalWith and partialWith).

This change resolves issues with previous implementations when used with the pipe method:

1
Schema.String.pipe(Schema.optional)

The following transformations have been added:

  • StringFromBase64
  • StringFromBase64Url
  • StringFromHex

For all the details, head over to our changelog.