Skip to content

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 the Schema.annotate(...) combinator.
  • Key annotations — describe a field’s position inside a Struct or Tuple (not its value). Attach with .annotateKey(...) or Schema.annotateKey(...).
  • Encoded-side annotations — describe the wire representation rather than the decoded value. Attach with Schema.annotateEncoded(...).

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))]))

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]
// }

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"]

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:

CombinatorAnnotation interfaceNotable keys beyond the common set
annotate / .annotateAnnotations.Bottom<T, ...>message, messageUnexpectedKey, identifier, parseOptions, meta, brands, toArbitrary
annotateKey / .annotateKeyAnnotations.Key<T>messageMissingKey
annotateEncodedAnnotations.Bottom<Encoded, []>same as annotate, applied to the encoded view
check builders / makeFilterAnnotations.Filtermessage, 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) // undefined

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)?.version
console.log(version?.[1]) // => 2 (the minor component)

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.

Annotations are the extension point for every derivation that ships with Schema:

  • JSON SchematoJsonSchemaDocument copies the documentation keys and uses identifier for $ref definition names; includeAnnotationKey opts in custom keys. See JSON Schema.
  • Error formatting — the default formatter consumes message, expected, identifier, title, and messageMissingKey / messageUnexpectedKey. See Error Formatting.
  • Arbitrary — the toArbitrary hook (on Annotations.Bottom / Annotations.Declaration) and the toArbitraryConstraint hook on filters let a schema control how property-test data is generated.
  • Custom derivations — anything you read via resolveAnnotations / SchemaAST.resolve can drive your own tooling. Module-augment Annotations.Annotations to 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"

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"

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"

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)"

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"]
// ))]))

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"

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" }

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"

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.

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"

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"

Shorthand for the identifier annotation.

import { Schema, SchemaAST } from "effect"
const schema = Schema.String.annotate({ identifier: "UserId" })
console.log(SchemaAST.resolveIdentifier(schema.ast)) // => "UserId"

Shorthand for the title annotation.

import { Schema, SchemaAST } from "effect"
const schema = Schema.String.annotate({ title: "Name" })
console.log(SchemaAST.resolveTitle(schema.ast)) // => "Name"

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"