Annotations
Annotations are metadata attached to a schema. They never change how a value is
decoded, encoded, or validated — they exist so that tooling can do a better
job. The default error formatter uses them to print friendlier messages, the
JSON Schema generator copies them into title / description / examples, and
derivations (Arbitrary, Equivalence, custom hooks) read them to control how they
build their result.
import { Schema } from "effect"
// A self-documenting schema. None of these keys affect parsing — they describe// the value so error messages, JSON Schema, and editors can show useful text.const Age = Schema.Number.pipe( Schema.annotate({ identifier: "Age", title: "Age", description: "A non-negative integer representing age in years", examples: [0, 18, 42] }))
// The annotations show up in the generated JSON Schema document:console.log(Schema.toJsonSchemaDocument(Age).schema)// {// type: "number",// title: "Age",// description: "A non-negative integer representing age in years",// examples: [0, 18, 42]// }Annotations come in three flavors depending on what you are describing:
- Schema annotations — describe a value type. Attach with
.annotate(...)or theSchema.annotate(...)combinator. - Key annotations — describe a field’s position inside a
StructorTuple(not its value). Attach with.annotateKey(...)orSchema.annotateKey(...). - Encoded-side annotations — describe the wire representation rather than
the decoded value. Attach with
Schema.annotateEncoded(...).
Why annotate
Section titled “Why annotate”Better error messages
Section titled “Better error messages”The default formatter (see Error Formatting) looks
at annotations when it builds a message. For a type mismatch it uses
identifier (then title) as the expected label; for a filter failure it
uses message, then expected.
import { Schema } from "effect"
const UserId = Schema.String.annotate({ identifier: "UserId" })
console.log(String(Schema.decodeUnknownExit(UserId)(123)))// Failure(Cause([Fail(SchemaError(Expected UserId, got 123))]))import { Schema } from "effect"
// `message` replaces the whole filter-failure message; `expected` only renames// the "Expected ..." label. Put these on the check, not the base schema.const Password = Schema.String.check( Schema.isMinLength(8, { message: "Password must be at least 8 characters" }))
console.log(String(Schema.decodeUnknownExit(Password)("short")))// Failure(Cause([Fail(SchemaError(Password must be at least 8 characters))]))Documentation generators
Section titled “Documentation generators”Schema.toJsonSchemaDocument copies the standard documentation annotations
(title, description, default, examples, readOnly, writeOnly,
format, contentEncoding, contentMediaType) straight into the output. See
JSON Schema for the full mapping.
import { Schema } from "effect"
const Port = Schema.Number.pipe( Schema.annotate({ title: "Port", description: "TCP port number", default: 8080, examples: [80, 443, 8080] }))
console.log(Schema.toJsonSchemaDocument(Port).schema)// {// type: "number",// title: "Port",// description: "TCP port number",// default: 8080,// examples: [80, 443, 8080]// }Identification and reuse
Section titled “Identification and reuse”An identifier gives a schema a stable name. JSON Schema generation uses it to
emit $ref definitions instead of inlining the same shape repeatedly, and the
formatter uses it as the expected label.
import { Schema } from "effect"
const Email = Schema.String.annotate({ identifier: "Email" })
const Contact = Schema.Struct({ primary: Email, backup: Email})
const doc = Schema.toJsonSchemaDocument(Contact)console.log(doc.schema)// { type: "object", properties: { primary: { $ref: "#/$defs/Email" }, ... }, ... }console.log(Object.keys(doc.definitions))// ["Email"]The annotation shape
Section titled “The annotation shape”Every annotation object is, at its widest, a record of string keys to
unknown values (Annotations.Annotations). On top of that, the typed
interfaces in the Schema.Annotations namespace describe the well-known keys
that tooling understands. The interface you get depends on the combinator:
| Combinator | Annotation interface | Notable keys beyond the common set |
|---|---|---|
annotate / .annotate | Annotations.Bottom<T, ...> | message, messageUnexpectedKey, identifier, parseOptions, meta, brands, toArbitrary |
annotateKey / .annotateKey | Annotations.Key<T> | messageMissingKey |
annotateEncoded | Annotations.Bottom<Encoded, []> | same as annotate, applied to the encoded view |
check builders / makeFilter | Annotations.Filter | message, expected, identifier, meta |
The common documentation keys (Annotations.Documentation<T>, which extends
Annotations.Augment) are shared by all of them:
import { Schema } from "effect"
// Every key here is recognized by the typed annotation interface.const schema = Schema.String.annotate({ // From Augment (map directly onto JSON Schema / OpenAPI fields): title: "Slug", description: "URL-safe identifier", documentation: "See RFC 3986 for the allowed character set", readOnly: true, format: "uri-reference", contentEncoding: "utf-8", contentMediaType: "text/plain", // From Documentation<T> (type-parametric): default: "untitled", examples: ["my-post", "hello-world"], // From Bottom<T, ...>: identifier: "Slug", message: "Expected a URL-safe slug"})Annotations are merged, not replaced — calling .annotate twice combines the
two objects (later keys win). Set a key to undefined to remove it.
import { Schema } from "effect"
const base = Schema.String.annotate({ title: "A", description: "first" })
const merged = base.annotate({ description: "second" })console.log(Schema.resolveAnnotations(merged)?.title) // "A"console.log(Schema.resolveAnnotations(merged)?.description) // "second"
const cleared = merged.annotate({ description: undefined })console.log(Schema.resolveAnnotations(cleared)?.description) // undefinedCustom annotations
Section titled “Custom annotations”Any extra key is preserved on the AST, so derivations and your own tooling can
read it. You can extend the Annotations.Annotations interface via module
augmentation to make a custom key type-safe.
import { Schema } from "effect"
// Make a `version` annotation known to the compiler everywhere.declare module "effect/Schema" { namespace Annotations { interface Annotations { readonly version?: | readonly [major: number, minor: number, patch: number] | undefined } }}
const schema = Schema.String.annotate({ version: [1, 2, 0] })const version = Schema.resolveAnnotations(schema)?.versionconsole.log(version?.[1]) // => 2 (the minor component)Annotations on checks
Section titled “Annotations on checks”Checks (filters) carry their own Annotations.Filter, which is where the
formatter looks for failure text. The default formatter checks message first,
then expected, then falls back to <filter>. Every built-in check builder
accepts an optional annotations argument, so you can override its message inline.
import { Schema } from "effect"
// `expected` renames the "Expected ..." label; `message` replaces the whole line.// `makeFilter`'s second argument is the filter's annotations.const Even = Schema.Number.check( Schema.makeFilter( (n) => n % 2 === 0, { expected: "an even number" } ))
console.log(String(Schema.decodeUnknownExit(Even)(3)))// Failure(Cause([Fail(SchemaError(Expected an even number, got 3))]))import { Schema } from "effect"
// Built-in checks take annotations as a trailing argument.const Username = Schema.String.check( Schema.isMinLength(3, { message: "Username is too short" }), Schema.isMaxLength(20, { message: "Username is too long" }))
console.log(String(Schema.decodeUnknownExit(Username)("ab")))// Failure(Cause([Fail(SchemaError(Username is too short))]))Schema.makeFilter (for .check) and the pipeable Schema.refine (a type
guard combinator) both take an Annotations.Filter argument, so custom checks
participate in the same scheme. See Filters for the full
check catalog.
How derivations consume annotations
Section titled “How derivations consume annotations”Annotations are the extension point for every derivation that ships with Schema:
- JSON Schema —
toJsonSchemaDocumentcopies the documentation keys and usesidentifierfor$refdefinition names;includeAnnotationKeyopts in custom keys. See JSON Schema. - Error formatting — the default formatter consumes
message,expected,identifier,title, andmessageMissingKey/messageUnexpectedKey. See Error Formatting. - Arbitrary — the
toArbitraryhook (onAnnotations.Bottom/Annotations.Declaration) and thetoArbitraryConstrainthook on filters let a schema control how property-test data is generated. - Custom derivations — anything you read via
resolveAnnotations/SchemaAST.resolvecan drive your own tooling. Module-augmentAnnotations.Annotationsto type your keys.
import { Schema } from "effect"
// A custom annotation read by your own code generator.const Field = Schema.String.annotate({ "x-db-column": "user_name" })console.log(Schema.resolveAnnotations(Field)?.["x-db-column"])// => "user_name"Reference
Section titled “Reference”annotate
Section titled “annotate”Adds metadata to the decoded (Type) side of a schema. Pipeable counterpart
of the .annotate method. Accepts Annotations.Bottom<T, ...>.
import { Schema } from "effect"
const Name = Schema.String.pipe( Schema.annotate({ title: "Name", description: "The user's display name" }))
console.log(Schema.resolveAnnotations(Name)?.title) // => "Name".annotate (method)
Section titled “.annotate (method)”The method form of annotate, available on every schema. Returns a rebuilt
schema of the same type with the annotations merged in.
import { Schema } from "effect"
const Name = Schema.String.annotate({ title: "Name" })console.log(Schema.resolveAnnotations(Name)?.title) // => "Name"annotateEncoded
Section titled “annotateEncoded”Adds metadata to the encoded (wire) side of a schema. Internally the schema
is flipped so Encoded becomes Type, annotated, then flipped back. Use this
to document what the serialized form looks like, independently of the decoded
value.
import { Schema } from "effect"
// Decoded side is a number; the encoded side is a string. Annotate each side// separately so each view of the schema documents itself.const schema = Schema.NumberFromString.pipe( Schema.annotate({ title: "Count (number)" }), Schema.annotateEncoded({ title: "Count (string on the wire)" }))
console.log(Schema.resolveAnnotations(schema)?.title)// => "Count (number)"console.log(Schema.toEncoded(schema).ast.annotations?.title)// => "Count (string on the wire)"annotateKey
Section titled “annotateKey”Adds key-level metadata to a field — describing its slot inside a Struct
or Tuple rather than its value type. Accepts Annotations.Key<T>, whose extra
key messageMissingKey overrides the error shown when the field is absent.
import { Schema } from "effect"
const Form = Schema.Struct({ username: Schema.String.pipe( Schema.annotateKey({ description: "The username used to log in", messageMissingKey: "Username is required" }) )})
console.log(String(Schema.decodeUnknownExit(Form)({})))// Failure(Cause([Fail(SchemaError(Username is required// at ["username"]// ))])).annotateKey (method)
Section titled “.annotateKey (method)”The method form of annotateKey, available on every schema. Useful inline
inside a Struct definition.
import { Schema } from "effect"
const field = Schema.Number.annotateKey({ description: "1-based page index" })console.log(Schema.resolveAnnotationsKey(field)?.description)// => "1-based page index"resolveAnnotations
Section titled “resolveAnnotations”Reads the merged schema annotations at runtime, typed as
Annotations.Bottom<T, ...>. If the schema has checks, the annotations come
from the last check; otherwise from the base node. Returns undefined when
there are none.
import { Schema } from "effect"
const Age = Schema.Number.annotate({ title: "Age", identifier: "Age" })console.log(Schema.resolveAnnotations(Age))// => { title: "Age", identifier: "Age" }resolveAnnotationsKey
Section titled “resolveAnnotationsKey”Reads the key-level annotations attached via annotateKey (stored on the AST’s
context, not on the schema node itself). Returns Annotations.Key<T> or
undefined.
import { Schema } from "effect"
const field = Schema.String.annotateKey({ messageMissingKey: "required" })console.log(Schema.resolveAnnotationsKey(field)?.messageMissingKey)// => "required"Reading annotations from the AST
Section titled “Reading annotations from the AST”Schema.resolveAnnotations / Schema.resolveAnnotationsKey are the typed,
schema-level readers. When you work at the AST level (e.g. building a custom
derivation) the SchemaAST module exposes lower-level resolvers that take an
AST and apply the same “last check wins” lookup.
SchemaAST.resolve
Section titled “SchemaAST.resolve”Returns the merged annotation record for an AST node, or undefined.
import { Schema, SchemaAST } from "effect"
const schema = Schema.String.annotate({ title: "Name" })console.log(SchemaAST.resolve(schema.ast)?.title) // => "Name"SchemaAST.resolveAt
Section titled “SchemaAST.resolveAt”Returns a single annotation value by key, using the same lookup.
import { Schema, SchemaAST } from "effect"
const schema = Schema.String.annotate({ title: "Name" })const getTitle = SchemaAST.resolveAt<string>("title")console.log(getTitle(schema.ast)) // => "Name"SchemaAST.resolveIdentifier
Section titled “SchemaAST.resolveIdentifier”Shorthand for the identifier annotation.
import { Schema, SchemaAST } from "effect"
const schema = Schema.String.annotate({ identifier: "UserId" })console.log(SchemaAST.resolveIdentifier(schema.ast)) // => "UserId"SchemaAST.resolveTitle
Section titled “SchemaAST.resolveTitle”Shorthand for the title annotation.
import { Schema, SchemaAST } from "effect"
const schema = Schema.String.annotate({ title: "Name" })console.log(SchemaAST.resolveTitle(schema.ast)) // => "Name"SchemaAST.resolveDescription
Section titled “SchemaAST.resolveDescription”Shorthand for the description annotation.
import { Schema, SchemaAST } from "effect"
const schema = Schema.String.annotate({ description: "A label" })console.log(SchemaAST.resolveDescription(schema.ast)) // => "A label"