Skip to content

Schema 0.64 (Release)

The To and From type extractors have been renamed to Type and Encoded respectively.

Before:

1
import * as S from "@effect/schema/Schema"
2
3
const schema = S.string
4
5
type SchemaType = S.Schema.To<typeof schema>
6
type SchemaEncoded = S.Schema.From<typeof schema>

Now:

1
import * as S from "@effect/schema/Schema"
2
3
const schema = S.string
4
5
type SchemaType = S.Schema.Type<typeof schema>
6
type SchemaEncoded = S.Schema.Encoded<typeof schema>

The reason for this change is that the terms “From” and “To” were too generic and depended on the context. For example, when encoding, the meaning of “From” and “To” were reversed.

As a consequence, the APIs AST.to, AST.from, Schema.to, and Schema.from have been renamed respectively to AST.typeAST, AST.encodedAST, Schema.typeSchema, and Schema.encodedSchema.

Now, in addition to the pipe method, all schemas have a annotations method that can be used to add annotations:

1
import * as S from "@effect/schema/Schema"
2
3
const Name = S.string.annotations({ identifier: "Name" })

For backward compatibility and to leverage a pipeline, you can still use the pipeable S.annotations API:

1
import * as S from "@effect/schema/Schema"
2
3
const Name = S.string.pipe(S.annotations({ identifier: "Name" }))

An “API Interface” is an interface specifically defined for a schema exported from @effect/schema or for a particular API exported from @effect/schema. Let’s see an example with a simple schema:

Example (An Age schema)

1
import * as S from "@effect/schema/Schema"
2
3
// API interface
4
interface Age extends S.Schema<number> {}
5
6
const Age: Age = S.number.pipe(S.between(0, 100))
7
8
// type AgeType = number
9
type AgeType = S.Schema.Type<typeof Age>
10
// type AgeEncoded = number
11
type AgeEncoded = S.Schema.Encoded<typeof Age>

The benefit is that when we hover over the Age schema, we see Age instead of Schema<number, number, never>. This is a small improvement if we only think about the Age schema, but as we’ll see shortly, these improvements in schema visualization add up, resulting in a significant improvement in the readability of our schemas.

Many of the built-in schemas exported from @effect/schema have been equipped with API interfaces, for example number or never.

1
import * as S from "@effect/schema/Schema"
2
3
// const number: S.$number
4
S.number
5
6
// const never: S.$never
7
S.never

Note. Notice that we had to add a $ suffix to the API interface name because we couldn’t simply use “number” since it’s a reserved name for the TypeScript number type.

Now let’s see an example with a combinator that, given an input schema for a certain type A, returns the schema of the pair readonly [A, A]:

Example (A pair combinator)

1
import * as S from "@effect/schema/Schema"
2
3
// API interface
4
export interface pair<S extends S.Schema.Any>
5
extends S.Schema<
6
readonly [S.Schema.Type<S>, S.Schema.Type<S>],
7
readonly [S.Schema.Encoded<S>, S.Schema.Encoded<S>],
8
S.Schema.Context<S>
9
> {}
10
11
// API
12
export const pair = <S extends S.Schema.Any>(schema: S): pair<S> =>
13
S.tuple(S.asSchema(schema), S.asSchema(schema))

Note: The S.Schema.Any helper represents any schema, except for never. For more information on the asSchema helper, refer to the following section “Understanding Opaque Names”.

If we try to use our pair combinator, we see that readability is also improved in this case:

1
// const Coords: pair<S.$number>
2
const Coords = pair(S.number)

In hover, we simply see pair<S.$number> instead of the old:

1
// const Coords: S.Schema<readonly [number, number], readonly [number, number], never>
2
const Coords = S.tuple(S.number, S.number)

The new name is not only shorter and more readable but also carries along the origin of the schema, which is a call to the pair combinator.

Opaque names generated in this way are very convenient, but sometimes there’s a need to see what the underlying types are, perhaps for debugging purposes while you declare your schemas. At any time, you can use the asSchema function, which returns an Schema<A, I, R> compatible with your opaque definition:

1
// const Coords: pair<S.$number>
2
const Coords = pair(S.number)
3
4
// const NonOpaqueCoords: S.Schema<readonly [number, number], readonly [number, number], never>
5
const NonOpaqueCoords = S.asSchema(Coords)

Note. The call to asSchema is negligible in terms of overhead since it’s nothing more than a glorified identity function.

Many of the built-in combinators exported from @effect/schema have been equipped with API interfaces, for example struct:

1
import * as S from "@effect/schema/Schema"
2
3
/*
4
const Person: S.struct<{
5
name: S.$string;
6
age: S.$number;
7
}>
8
*/
9
const Person = S.struct({
10
name: S.string,
11
age: S.number
12
})

In hover, we simply see:

1
const Person: S.struct<{
2
name: S.$string
3
age: S.$number
4
}>

instead of the old:

1
const Person: S.Schema<
2
{
3
readonly name: string
4
readonly age: number
5
},
6
{
7
readonly name: string
8
readonly age: number
9
},
10
never
11
>

The benefits of API interfaces don’t end with better readability; in fact, the driving force behind the introduction of API interfaces arises more from the need to expose some important information about the schemas that users generate. Let’s see some examples related to literals and structs:

Example (Exposed literals)

Now when we define literals, we can retrieve them using the literals field exposed by the generated schema:

1
import * as S from "@effect/schema/Schema"
2
3
// const myliterals: S.literal<["A", "B"]>
4
const myliterals = S.literal("A", "B")
5
6
// literals: readonly ["A", "B"]
7
myliterals.literals
8
9
console.log(myliterals.literals) // Output: [ 'A', 'B' ]

Example (Exposed fields)

Similarly to what we’ve seen for literals, when we define a struct, we can retrieve its fields:

1
import * as S from "@effect/schema/Schema"
2
3
/*
4
const Person: S.struct<{
5
name: S.$string;
6
age: S.$number;
7
}>
8
*/
9
const Person = S.struct({
10
name: S.string,
11
age: S.number
12
})
13
14
/*
15
fields: {
16
readonly name: S.$string;
17
readonly age: S.$number;
18
}
19
*/
20
Person.fields
21
22
console.log(Person.fields)
23
/*
24
{
25
name: Schema {
26
ast: StringKeyword { _tag: 'StringKeyword', annotations: [Object] },
27
...
28
},
29
age: Schema {
30
ast: NumberKeyword { _tag: 'NumberKeyword', annotations: [Object] },
31
...
32
}
33
}
34
*/

Being able to retrieve the fields is particularly advantageous when you want to extend a struct with new fields; now you can do it simply using the spread operator:

1
import * as S from "@effect/schema/Schema"
2
3
const Person = S.struct({
4
name: S.string,
5
age: S.number
6
})
7
8
/*
9
const PersonWithId: S.struct<{
10
id: S.$number;
11
name: S.$string;
12
age: S.$number;
13
}>
14
*/
15
const PersonWithId = S.struct({
16
...Person.fields,
17
id: S.number
18
})

The list of APIs equipped with API interfaces is extensive; here we provide only the main ones just to give you an idea of the new development possibilities that have opened up:

1
import * as S from "@effect/schema/Schema"
2
3
// ------------------------
4
// array value
5
// ------------------------
6
7
// value: S.$string
8
S.array(S.string).value
9
10
// ------------------------
11
// record key and value
12
// ------------------------
13
14
// key: S.$string
15
S.record(S.string, S.number).key
16
// value: S.$number
17
S.record(S.string, S.number).value
18
19
// ------------------------
20
// union members
21
// ------------------------
22
23
// members: readonly [S.$string, S.$number]
24
S.union(S.string, S.number).members
25
26
// ------------------------
27
// tuple elements
28
// ------------------------
29
30
// elements: readonly [S.$string, S.$number]
31
S.tuple(S.string, S.number).elements

All the API interfaces equipped with schemas and built-in combinators are compatible with the annotations method, meaning that their type is not lost but remains the original one before annotation:

1
import * as S from "@effect/schema/Schema"
2
3
// const Name: S.$string
4
const Name = S.string.annotations({ identifier: "Name" })

As you can see, the type of Name is still $string and has not been lost, becoming Schema<string, string, never>.

This doesn’t happen by default with API interfaces defined in userland:

1
import * as S from "@effect/schema/Schema"
2
3
// API interface
4
interface Age extends S.Schema<number> {}
5
6
const Age: Age = S.number.pipe(S.between(0, 100))
7
8
// const AnotherAge: S.Schema<number, number, never>
9
const AnotherAge = Age.annotations({ identifier: "AnotherAge" })

However, the fix is very simple; just modify the definition of the Age API interface using the Annotable interface exported by @effect/schema:

1
import * as S from "@effect/schema/Schema"
2
3
// API interface
4
interface Age extends S.Annotable<Age, number> {}
5
6
const Age: Age = S.number.pipe(S.between(0, 100))
7
8
// const AnotherAge: Age
9
const AnotherAge = Age.annotations({ identifier: "AnotherAge" })

Now, defining a Class requires an identifier (to avoid dual package hazard):

1
// new required identifier v
2
// v
3
class A extends S.Class<A>("A")({ a: S.string }) {}

Similar to the case with struct, classes now also expose fields:

1
import * as S from "@effect/schema/Schema"
2
3
class A extends S.Class<A>("A")({ a: S.string }) {}
4
5
/*
6
fields: {
7
readonly a: S.$string;
8
}
9
*/
10
A.fields

Now the struct constructor optionally accepts a list of key/value pairs representing index signatures:

1
const struct = (props, ...indexSignatures)

Example

1
import * as S from "@effect/schema/Schema"
2
3
/*
4
const opaque: S.typeLiteral<{
5
a: S.$number;
6
}, readonly [{
7
readonly key: S.$string;
8
readonly value: S.$number;
9
}]>
10
*/
11
const opaque = S.struct(
12
{
13
a: S.number
14
},
15
{ key: S.string, value: S.number }
16
)
17
18
/*
19
const nonOpaque: S.Schema<{
20
readonly [x: string]: number;
21
readonly a: number;
22
}, {
23
readonly [x: string]: number;
24
readonly a: number;
25
}, never>
26
*/
27
const nonOpaque = S.asSchema(opaque)

Since the record constructor returns a schema that exposes both the key and the value, instead of passing a bare object { key, value }, you can use the record constructor:

1
import * as S from "@effect/schema/Schema"
2
3
/*
4
const opaque: S.typeLiteral<{
5
a: S.$number;
6
}, readonly [S.record<S.$string, S.$number>]>
7
*/
8
const opaque = S.struct(
9
{
10
a: S.number
11
},
12
S.record(S.string, S.number)
13
)
14
15
/*
16
const nonOpaque: S.Schema<{
17
readonly [x: string]: number;
18
readonly a: number;
19
}, {
20
readonly [x: string]: number;
21
readonly a: number;
22
}, never>
23
*/
24
const nonOpaque = S.asSchema(opaque)

The tuple constructor has been improved to allow building any variant supported by TypeScript:

As before, to define a tuple with required elements, simply specify the list of elements:

1
import * as S from "@effect/schema/Schema"
2
3
// const opaque: S.tuple<[S.$string, S.$number]>
4
const opaque = S.tuple(S.string, S.number)
5
6
// const nonOpaque: S.Schema<readonly [string, number], readonly [string, number], never>
7
const nonOpaque = S.asSchema(opaque)

To define an optional element, wrap the schema of the element with the optionalElement modifier:

1
import * as S from "@effect/schema/Schema"
2
3
// const opaque: S.tuple<[S.$string, S.OptionalElement<S.$number>]>
4
const opaque = S.tuple(S.string, S.optionalElement(S.number))
5
6
// const nonOpaque: S.Schema<readonly [string, number?], readonly [string, number?], never>
7
const nonOpaque = S.asSchema(opaque)

To define rest elements, follow the list of elements (required or optional) with an element for the rest:

1
import * as S from "@effect/schema/Schema"
2
3
// const opaque: S.tupleType<readonly [S.$string, S.OptionalElement<S.$number>], [S.$boolean]>
4
const opaque = S.tuple([S.string, S.optionalElement(S.number)], S.boolean)
5
6
// const nonOpaque: S.Schema<readonly [string, number?, ...boolean[]], readonly [string, number?, ...boolean[]], never>
7
const nonOpaque = S.asSchema(opaque)

and optionally other elements that follow the rest:

1
import * as S from "@effect/schema/Schema"
2
3
// const opaque: S.tupleType<readonly [S.$string, S.OptionalElement<S.$number>], [S.$boolean, S.$string]>
4
const opaque = S.tuple(
5
[S.string, S.optionalElement(S.number)],
6
S.boolean,
7
S.string
8
)
9
10
// const nonOpaque: S.Schema<readonly [string, number | undefined, ...boolean[], string], readonly [string, number | undefined, ...boolean[], string], never>
11
const nonOpaque = S.asSchema(opaque)

The definition of property signatures has been completely redesigned to allow for any type of transformation. Recall that a PropertySignature generally represents a transformation from a “From” field:

1
{
2
fromKey: fromType
3
}

to a “To” field:

1
{
2
toKey: toType
3
}

Let’s start with the simple definition of a property signature that can be used to add annotations:

1
import * as S from "@effect/schema/Schema"
2
3
/*
4
const Person: S.struct<{
5
name: S.$string;
6
age: S.PropertySignature<":", number, never, ":", string, never>;
7
}>
8
*/
9
const Person = S.struct({
10
name: S.string,
11
age: S.propertySignature(S.NumberFromString, {
12
annotations: { identifier: "Age" }
13
})
14
})

Let’s delve into the details of all the information contained in the type of a PropertySignature:

1
age: PropertySignature<
2
ToToken,
3
ToType,
4
FromKey,
5
FromToken,
6
FromType,
7
Context
8
>
  • age: is the key of the “To” field
  • ToToken: either "?:" or ":", "?:" indicates that the “To” field is optional, ":" indicates that the “To” field is required
  • ToType: the type of the “To” field
  • FromKey (optional, default = never): indicates the key from the field from which the transformation starts, by default it is equal to the key of the “To” field (i.e., "age" in this case)
  • FormToken: either "?:" or ":", "?:" indicates that the “From” field is optional, ":" indicates that the “From” field is required
  • FromType: the type of the “From” field

In our case, the type

1
PropertySignature<":", number, never, ":", string, never>

indicates that there is the following transformation:

  • age is the key of the “To” field
  • ToToken = ":" indicates that the age field is required
  • ToType = number indicates that the 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 = string indicates that the decoding occurs from a string type age field

Let’s see an example of decoding:

1
console.log(S.decodeUnknownSync(Person)({ name: "name", age: "18" }))
2
// Output: { name: 'name', age: 18 }

Now, suppose the field from which decoding occurs is named "AGE", but for our model, we want to keep the name in lowercase "age". To achieve this result, we need to map the field key from "AGE" to "age", and to do that, we can use the fromKey combinator:

1
import * as S from "@effect/schema/Schema"
2
3
/*
4
const Person: S.struct<{
5
name: S.$string;
6
age: S.PropertySignature<":", number, "AGE", ":", string, never>;
7
}>
8
*/
9
const Person = S.struct({
10
name: S.string,
11
age: S.propertySignature(S.NumberFromString).pipe(S.fromKey("AGE"))
12
})

This modification is represented in the type of the created PropertySignature:

1
// fromKey ----------------------v
2
PropertySignature<":", number, "AGE", ":", string, never>

Now, let’s see an example of decoding:

1
console.log(S.decodeUnknownSync(Person)({ name: "name", AGE: "18" }))
2
// Output: { name: 'name', age: 18 }

Now messages are not only of type string but can return an Effect so that they can have dependencies (for example, from an internationalization service). Let’s see the outline of a similar situation with a very simplified example for demonstration purposes:

1
import * as S from "@effect/schema/Schema"
2
import * as TreeFormatter from "@effect/schema/TreeFormatter"
3
import * as Context from "effect/Context"
4
import * as Effect from "effect/Effect"
5
import * as Either from "effect/Either"
6
import * as Option from "effect/Option"
7
8
// internationalization service
9
class Messages extends Context.Tag("Messages")<
10
Messages,
11
{
12
NonEmpty: string
13
}
14
>() {}
15
16
const Name = S.NonEmpty.pipe(
17
S.message(() =>
18
Effect.gen(function* () {
19
const service = yield* Effect.serviceOption(Messages)
20
return Option.match(service, {
21
onNone: () => "Invalid string",
22
onSome: (messages) => messages.NonEmpty
23
})
24
})
25
)
26
)
27
28
S.decodeUnknownSync(Name)("") // => throws "Invalid string"
29
30
const result = S.decodeUnknownEither(Name)("").pipe(
31
Either.mapLeft((error) =>
32
TreeFormatter.formatErrorEffect(error).pipe(
33
Effect.provideService(Messages, {
34
NonEmpty: "should be non empty"
35
}),
36
Effect.runSync
37
)
38
)
39
)
40
41
console.log(result) // => { _id: 'Either', _tag: 'Left', left: 'should be non empty' }
  • The Format module has been removed
  • Tuple has been refactored to TupleType, and its _tag has consequently been renamed. The type of its rest property has changed from Option.Option<ReadonlyArray.NonEmptyReadonlyArray<AST>> to ReadonlyArray<AST>.

  • Transform has been refactored to Transformation, and its _tag property has consequently been renamed. Its property transformation has now the type TransformationKind = FinalTransformation | ComposeTransformation | TypeLiteralTransformation.

  • createRecord has been removed

  • AST.to has been renamed to AST.typeAST

  • AST.from has been renamed to AST.encodedAST

  • ExamplesAnnotation and DefaultAnnotation now accept a type parameter

  • format has been removed: Before

    1
    AST.format(ast, verbose?)

    Now

    1
    ast.toString(verbose?)
  • setAnnotation has been removed (use annotations instead)

  • mergeAnnotations has been renamed to annotations

  • The ParseResult module now uses classes and custom constructors have been removed: Before

    1
    import * as ParseResult from "@effect/schema/ParseResult"
    2
    3
    ParseResult.type(ast, actual)

    Now

    1
    import * as ParseResult from "@effect/schema/ParseResult"
    2
    3
    new ParseResult.Type(ast, actual)
  • Transform has been refactored to Transformation, and its kind property now accepts "Encoded", "Transformation", or "Type" as values

  • move defaultParseOption from Parser.ts to AST.ts

  • uniqueSymbol has been renamed to uniqueSymbolFromSelf

  • Schema.Schema.To has been renamed to Schema.Schema.Type, and Schema.to to Schema.typeSchema

  • Schema.Schema.From has been renamed to Schema.Schema.Encoded, and Schema.from to Schema.encodedSchema

  • The type parameters of TaggedRequest have been swapped

  • The signature of PropertySignature has been changed from PropertySignature<From, FromOptional, To, ToOptional> to PropertySignature<ToToken extends Token, To, Key extends PropertyKey, FromToken extends Token, From, R>

  • Class APIs

    • Class APIs now expose fields and require an identifier
      1
      class A extends S.Class<A>()({ a: S.string }) {}
      2
      class A extends S.Class<A>("A")({ a: S.string }) {}
  • element and rest have been removed in favor of array and tuple:

    Before

    1
    import * as S from "@effect/schema/Schema"
    2
    3
    const schema1 = S.tuple().pipe(S.rest(S.number), S.element(S.boolean))
    4
    5
    const schema2 = S.tuple(S.string).pipe(
    6
    S.rest(S.number),
    7
    S.element(S.boolean)
    8
    )

    Now

    1
    import * as S from "@effect/schema/Schema"
    2
    3
    const schema1 = S.array(S.number, S.boolean)
    4
    5
    const schema2 = S.tuple([S.string], S.number, S.boolean)
  • optionalElement has been refactored:

    Before

    1
    import * as S from "@effect/schema/Schema"
    2
    3
    const schema = S.tuple(S.string).pipe(S.optionalElement(S.number))

    Now

    1
    import * as S from "@effect/schema/Schema"
    2
    3
    const schema = S.tuple(S.string, S.optionalElement(S.number))
  • use TreeFormatter in BrandSchemas

  • Schema annotations interfaces have been refactored:

    • add PropertySignatureAnnotations (baseline)
    • remove DocAnnotations
    • rename DeclareAnnotations to Annotations
  • propertySignatureAnnotations has been replaced by the propertySignature constructor which owns a annotations method Before

    1
    S.string.pipe(
    2
    S.propertySignatureAnnotations({ description: "description" })
    3
    )
    4
    5
    S.optional(S.string, {
    6
    exact: true,
    7
    annotations: { description: "description" }
    8
    })

    Now

    1
    S.propertySignatureDeclaration(S.string).annotations({
    2
    description: "description"
    3
    })
    4
    5
    S.optional(S.string, { exact: true }).annotations({
    6
    description: "description"
    7
    })
  • The type parameters of SerializableWithResult and WithResult have been swapped
  • enhance the struct API to allow records:

    1
    const schema1 = S.struct(
    2
    { a: S.number },
    3
    { key: S.string, value: S.number }
    4
    )
    5
    // or
    6
    const schema2 = S.struct({ a: S.number }, S.record(S.string, S.number))
  • enhance the extend API to allow nested (non-overlapping) fields:

    1
    const A = S.struct({ a: S.struct({ b: S.string }) })
    2
    const B = S.struct({ a: S.struct({ c: S.number }) })
    3
    const schema = S.extend(A, B)
    4
    /*
    5
    same as:
    6
    const schema = S.struct({
    7
    a: S.struct({
    8
    b: S.string,
    9
    c: S.number
    10
    })
    11
    })
    12
    */
  • add Annotable interface

  • add asSchema

  • add add Schema.Any, Schema.All, Schema.AnyNoContext helpers

  • refactor annotations API to be a method within the Schema interface

  • add support for AST.keyof, AST.getPropertySignatures, Parser.getSearchTree to Classes

  • fix BrandAnnotation type and add getBrandAnnotation

  • add annotations? parameter to Class constructors:

    1
    import * as AST from "@effect/schema/AST"
    2
    import * as S from "@effect/schema/Schema"
    3
    4
    class A extends S.Class<A>()(
    5
    {
    6
    a: S.string
    7
    },
    8
    { description: "some description..." } // <= annotations
    9
    ) {}
    10
    11
    console.log(AST.getDescriptionAnnotation((A.ast as AST.Transform).to))
    12
    // => { _id: 'Option', _tag: 'Some', value: 'some description...' }