Schema 0.64 (Release)
The To
and From
type extractors have been renamed to Type
and Encoded
respectively.
Before:
1import * as S from "@effect/schema/Schema"2
3const schema = S.string4
5type SchemaType = S.Schema.To<typeof schema>6type SchemaEncoded = S.Schema.From<typeof schema>
Now:
1import * as S from "@effect/schema/Schema"2
3const schema = S.string4
5type SchemaType = S.Schema.Type<typeof schema>6type 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:
1import * as S from "@effect/schema/Schema"2
3const Name = S.string.annotations({ identifier: "Name" })
For backward compatibility and to leverage a pipeline, you can still use the pipeable S.annotations
API:
1import * as S from "@effect/schema/Schema"2
3const 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)
1import * as S from "@effect/schema/Schema"2
3// API interface4interface Age extends S.Schema<number> {}5
6const Age: Age = S.number.pipe(S.between(0, 100))7
8// type AgeType = number9type AgeType = S.Schema.Type<typeof Age>10// type AgeEncoded = number11type 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
.
1import * as S from "@effect/schema/Schema"2
3// const number: S.$number4S.number5
6// const never: S.$never7S.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)
1import * as S from "@effect/schema/Schema"2
3// API interface4export 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// API12export 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>2const 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>2const 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>2const Coords = pair(S.number)3
4// const NonOpaqueCoords: S.Schema<readonly [number, number], readonly [number, number], never>5const 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
:
1import * as S from "@effect/schema/Schema"2
3/*4const Person: S.struct<{5 name: S.$string;6 age: S.$number;7}>8*/9const Person = S.struct({10 name: S.string,11 age: S.number12})
In hover, we simply see:
1const Person: S.struct<{2 name: S.$string3 age: S.$number4}>
instead of the old:
1const Person: S.Schema<2 {3 readonly name: string4 readonly age: number5 },6 {7 readonly name: string8 readonly age: number9 },10 never11>
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:
1import * as S from "@effect/schema/Schema"2
3// const myliterals: S.literal<["A", "B"]>4const myliterals = S.literal("A", "B")5
6// literals: readonly ["A", "B"]7myliterals.literals8
9console.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
:
1import * as S from "@effect/schema/Schema"2
3/*4const Person: S.struct<{5 name: S.$string;6 age: S.$number;7}>8*/9const Person = S.struct({10 name: S.string,11 age: S.number12})13
14/*15fields: {16 readonly name: S.$string;17 readonly age: S.$number;18}19*/20Person.fields21
22console.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:
1import * as S from "@effect/schema/Schema"2
3const Person = S.struct({4 name: S.string,5 age: S.number6})7
8/*9const PersonWithId: S.struct<{10 id: S.$number;11 name: S.$string;12 age: S.$number;13}>14*/15const PersonWithId = S.struct({16 ...Person.fields,17 id: S.number18})
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:
1import * as S from "@effect/schema/Schema"2
3// ------------------------4// array value5// ------------------------6
7// value: S.$string8S.array(S.string).value9
10// ------------------------11// record key and value12// ------------------------13
14// key: S.$string15S.record(S.string, S.number).key16// value: S.$number17S.record(S.string, S.number).value18
19// ------------------------20// union members21// ------------------------22
23// members: readonly [S.$string, S.$number]24S.union(S.string, S.number).members25
26// ------------------------27// tuple elements28// ------------------------29
30// elements: readonly [S.$string, S.$number]31S.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:
1import * as S from "@effect/schema/Schema"2
3// const Name: S.$string4const 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:
1import * as S from "@effect/schema/Schema"2
3// API interface4interface Age extends S.Schema<number> {}5
6const Age: Age = S.number.pipe(S.between(0, 100))7
8// const AnotherAge: S.Schema<number, number, never>9const 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
:
1import * as S from "@effect/schema/Schema"2
3// API interface4interface Age extends S.Annotable<Age, number> {}5
6const Age: Age = S.number.pipe(S.between(0, 100))7
8// const AnotherAge: Age9const AnotherAge = Age.annotations({ identifier: "AnotherAge" })
Now, defining a Class
requires an identifier (to avoid dual package hazard):
1// new required identifier v2// v3class A extends S.Class<A>("A")({ a: S.string }) {}
Similar to the case with struct
, classes now also expose fields
:
1import * as S from "@effect/schema/Schema"2
3class A extends S.Class<A>("A")({ a: S.string }) {}4
5/*6fields: {7 readonly a: S.$string;8}9*/10A.fields
Now the struct
constructor optionally accepts a list of key/value pairs representing index signatures:
1const struct = (props, ...indexSignatures)
Example
1import * as S from "@effect/schema/Schema"2
3/*4const opaque: S.typeLiteral<{5 a: S.$number;6}, readonly [{7 readonly key: S.$string;8 readonly value: S.$number;9}]>10*/11const opaque = S.struct(12 {13 a: S.number14 },15 { key: S.string, value: S.number }16)17
18/*19const 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*/27const 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:
1import * as S from "@effect/schema/Schema"2
3/*4const opaque: S.typeLiteral<{5 a: S.$number;6}, readonly [S.record<S.$string, S.$number>]>7*/8const opaque = S.struct(9 {10 a: S.number11 },12 S.record(S.string, S.number)13)14
15/*16const 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*/24const 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:
1import * as S from "@effect/schema/Schema"2
3// const opaque: S.tuple<[S.$string, S.$number]>4const opaque = S.tuple(S.string, S.number)5
6// const nonOpaque: S.Schema<readonly [string, number], readonly [string, number], never>7const nonOpaque = S.asSchema(opaque)
To define an optional element, wrap the schema of the element with the optionalElement
modifier:
1import * as S from "@effect/schema/Schema"2
3// const opaque: S.tuple<[S.$string, S.OptionalElement<S.$number>]>4const opaque = S.tuple(S.string, S.optionalElement(S.number))5
6// const nonOpaque: S.Schema<readonly [string, number?], readonly [string, number?], never>7const nonOpaque = S.asSchema(opaque)
To define rest elements, follow the list of elements (required or optional) with an element for the rest:
1import * as S from "@effect/schema/Schema"2
3// const opaque: S.tupleType<readonly [S.$string, S.OptionalElement<S.$number>], [S.$boolean]>4const 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>7const nonOpaque = S.asSchema(opaque)
and optionally other elements that follow the rest:
1import * as S from "@effect/schema/Schema"2
3// const opaque: S.tupleType<readonly [S.$string, S.OptionalElement<S.$number>], [S.$boolean, S.$string]>4const opaque = S.tuple(5 [S.string, S.optionalElement(S.number)],6 S.boolean,7 S.string8)9
10// const nonOpaque: S.Schema<readonly [string, number | undefined, ...boolean[], string], readonly [string, number | undefined, ...boolean[], string], never>11const 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: fromType3}
to a “To” field:
1{2 toKey: toType3}
Let’s start with the simple definition of a property signature that can be used to add annotations:
1import * as S from "@effect/schema/Schema"2
3/*4const Person: S.struct<{5 name: S.$string;6 age: S.PropertySignature<":", number, never, ":", string, never>;7}>8*/9const 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
:
1age: PropertySignature<2 ToToken,3 ToType,4 FromKey,5 FromToken,6 FromType,7 Context8>
age
: is the key of the “To” fieldToToken
: either"?:"
or":"
,"?:"
indicates that the “To” field is optional,":"
indicates that the “To” field is requiredToType
: the type of the “To” fieldFromKey
(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 requiredFromType
: the type of the “From” field
In our case, the type
1PropertySignature<":", number, never, ":", string, never>
indicates that there is the following transformation:
age
is the key of the “To” fieldToToken = ":"
indicates that theage
field is requiredToType = number
indicates that the type of theage
field isnumber
FromKey = never
indicates that the decoding occurs from the same field namedage
FormToken = "."
indicates that the decoding occurs from a requiredage
fieldFromType = string
indicates that the decoding occurs from astring
typeage
field
Let’s see an example of decoding:
1console.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:
1import * as S from "@effect/schema/Schema"2
3/*4const Person: S.struct<{5 name: S.$string;6 age: S.PropertySignature<":", number, "AGE", ":", string, never>;7}>8*/9const 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 ----------------------v2PropertySignature<":", number, "AGE", ":", string, never>
Now, let’s see an example of decoding:
1console.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:
1import * as S from "@effect/schema/Schema"2import * as TreeFormatter from "@effect/schema/TreeFormatter"3import * as Context from "effect/Context"4import * as Effect from "effect/Effect"5import * as Either from "effect/Either"6import * as Option from "effect/Option"7
8// internationalization service9class Messages extends Context.Tag("Messages")<10 Messages,11 {12 NonEmpty: string13 }14>() {}15
16const 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.NonEmpty23 })24 })25 )26)27
28S.decodeUnknownSync(Name)("") // => throws "Invalid string"29
30const 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.runSync37 )38 )39)40
41console.log(result) // => { _id: 'Either', _tag: 'Left', left: 'should be non empty' }
- The
Format
module has been removed
-
Tuple
has been refactored toTupleType
, and its_tag
has consequently been renamed. The type of itsrest
property has changed fromOption.Option<ReadonlyArray.NonEmptyReadonlyArray<AST>>
toReadonlyArray<AST>
. -
Transform
has been refactored toTransformation
, and its_tag
property has consequently been renamed. Its propertytransformation
has now the typeTransformationKind = FinalTransformation | ComposeTransformation | TypeLiteralTransformation
. -
createRecord
has been removed -
AST.to
has been renamed toAST.typeAST
-
AST.from
has been renamed toAST.encodedAST
-
ExamplesAnnotation
andDefaultAnnotation
now accept a type parameter -
format
has been removed: Before1AST.format(ast, verbose?)Now
1ast.toString(verbose?) -
setAnnotation
has been removed (useannotations
instead) -
mergeAnnotations
has been renamed toannotations
-
The
ParseResult
module now uses classes and custom constructors have been removed: Before1import * as ParseResult from "@effect/schema/ParseResult"23ParseResult.type(ast, actual)Now
1import * as ParseResult from "@effect/schema/ParseResult"23new ParseResult.Type(ast, actual) -
Transform
has been refactored toTransformation
, and itskind
property now accepts"Encoded"
,"Transformation"
, or"Type"
as values -
move
defaultParseOption
fromParser.ts
toAST.ts
-
uniqueSymbol
has been renamed touniqueSymbolFromSelf
-
Schema.Schema.To
has been renamed toSchema.Schema.Type
, andSchema.to
toSchema.typeSchema
-
Schema.Schema.From
has been renamed toSchema.Schema.Encoded
, andSchema.from
toSchema.encodedSchema
-
The type parameters of
TaggedRequest
have been swapped -
The signature of
PropertySignature
has been changed fromPropertySignature<From, FromOptional, To, ToOptional>
toPropertySignature<ToToken extends Token, To, Key extends PropertyKey, FromToken extends Token, From, R>
-
Class APIs
- Class APIs now expose
fields
and require an identifier1class A extends S.Class<A>()({ a: S.string }) {}2class A extends S.Class<A>("A")({ a: S.string }) {}
- Class APIs now expose
-
element
andrest
have been removed in favor ofarray
andtuple
:Before
1import * as S from "@effect/schema/Schema"23const schema1 = S.tuple().pipe(S.rest(S.number), S.element(S.boolean))45const schema2 = S.tuple(S.string).pipe(6S.rest(S.number),7S.element(S.boolean)8)Now
1import * as S from "@effect/schema/Schema"23const schema1 = S.array(S.number, S.boolean)45const schema2 = S.tuple([S.string], S.number, S.boolean) -
optionalElement
has been refactored:Before
1import * as S from "@effect/schema/Schema"23const schema = S.tuple(S.string).pipe(S.optionalElement(S.number))Now
1import * as S from "@effect/schema/Schema"23const schema = S.tuple(S.string, S.optionalElement(S.number)) -
use
TreeFormatter
inBrandSchema
s -
Schema annotations interfaces have been refactored:
- add
PropertySignatureAnnotations
(baseline) - remove
DocAnnotations
- rename
DeclareAnnotations
toAnnotations
- add
-
propertySignatureAnnotations
has been replaced by thepropertySignature
constructor which owns aannotations
method Before1S.string.pipe(2S.propertySignatureAnnotations({ description: "description" })3)45S.optional(S.string, {6exact: true,7annotations: { description: "description" }8})Now
1S.propertySignatureDeclaration(S.string).annotations({2description: "description"3})45S.optional(S.string, { exact: true }).annotations({6description: "description"7})
- The type parameters of
SerializableWithResult
andWithResult
have been swapped
-
enhance the
struct
API to allow records:1const schema1 = S.struct(2{ a: S.number },3{ key: S.string, value: S.number }4)5// or6const schema2 = S.struct({ a: S.number }, S.record(S.string, S.number)) -
enhance the
extend
API to allow nested (non-overlapping) fields:1const A = S.struct({ a: S.struct({ b: S.string }) })2const B = S.struct({ a: S.struct({ c: S.number }) })3const schema = S.extend(A, B)4/*5same as:6const schema = S.struct({7a: S.struct({8b: S.string,9c: S.number10})11})12*/ -
add
Annotable
interface -
add
asSchema
-
add add
Schema.Any
,Schema.All
,Schema.AnyNoContext
helpers -
refactor
annotations
API to be a method within theSchema
interface -
add support for
AST.keyof
,AST.getPropertySignatures
,Parser.getSearchTree
to Classes -
fix
BrandAnnotation
type and addgetBrandAnnotation
-
add
annotations?
parameter to Class constructors:1import * as AST from "@effect/schema/AST"2import * as S from "@effect/schema/Schema"34class A extends S.Class<A>()(5{6a: S.string7},8{ description: "some description..." } // <= annotations9) {}1011console.log(AST.getDescriptionAnnotation((A.ast as AST.Transform).to))12// => { _id: 'Option', _tag: 'Some', value: 'some description...' }