/** * Describes problems found while decoding, encoding, or checking data with * schemas. * * An `Issue` records what failed and, for nested data, where the failure * happened. The Schema system uses these values for missing keys, unexpected * keys, invalid types, invalid values, failed filters, failed transformations, * and alternatives that did not match. This module also formats issues. * * @stability stable * @since 4.0.0 */ import * as Arr from "./Array.ts" import { format, formatPath, type Formatter as FormatterI } from "./Formatter.ts" import * as InternalAnnotations from "./internal/schema/annotations.ts" import * as InternalParser from "./internal/schema/parser.ts" import { hasProperty } from "./Predicate.ts" import type * as Schema from "./Schema.ts" import type * as SchemaAST from "./SchemaAST.ts" import type { StandardSchemaV1 } from "./StandardSchema.ts" const TypeId = "~effect/SchemaIssue/Issue" /** * Returns `true` if the given value is an {@link Issue}. * * **When to use** * * Use when you need to narrow an `unknown` value to `Issue` in error-handling * code, such as distinguishing an `Issue` from other error types in a catch-all * handler. * * **Details** * * - Checks for the internal `TypeId` brand on the value. * * **Example** (Type-guarding an unknown error) * * ```ts import.meta.vitest * import { SchemaIssue } from "effect" * * const issue = new SchemaIssue.MissingKey(undefined) * SchemaIssue.isIssue(issue) // => true * SchemaIssue.isIssue("not an issue") // => false * ``` * * @see {@link Issue} * * @stability stable * @category guards * @since 4.0.0 */ export function isIssue(u: unknown): u is Issue { return hasProperty(u, TypeId) && u[TypeId] === TypeId } /** * Returns `true` when an issue contains an input reported by the schema parser. * * **When to use** * * Use when reading `Issue.input`, especially when `undefined` is a valid input * value. * * **Details** * * Reported input is stored as an own property. This guard checks for that * property and narrows `input` from optional to required. * * **Example** (Reading a reported input) * * ```ts import.meta.vitest * import { Result, Schema, SchemaIssue } from "effect" * * const result = Schema.decodeUnknownResult(Schema.String)(1, { reportInput: true }) * if (Result.isFailure(result) && SchemaIssue.hasInput(result.failure.issue)) { * result.failure.issue.input // => 1 * } * ``` * * @see {@link Issue} for the complete issue model * * @stability stable * @category guards * @since 4.0.0 */ export function hasInput(issue: Issue): issue is Issue & { readonly input: unknown } { return Object.hasOwn(issue, "input") } /** * Union of all terminal (leaf) issue types that have no inner `Issue` children. * * **When to use** * * Use when constraining formatter hooks to only handle terminal nodes or when * pattern matching on the `_tag` of an issue and only leaf nodes matter. * * **Details** * * Members: {@link InvalidType}, {@link InvalidValue}, {@link MissingKey}, * {@link UnexpectedKey}, {@link Forbidden}, {@link OneOf}. * * @see {@link Issue} — the full union including composite nodes * @see {@link LeafHook} — formatter hook that operates on `Leaf` values * * @stability stable * @category models * @since 4.0.0 */ export type Leaf = | InvalidType | InvalidValue | MissingKey | UnexpectedKey | Forbidden | OneOf /** * The root discriminated union of all validation error nodes. * * **When to use** * * Use when typing the error channel in `Effect` results from * schema parsing, or when writing custom formatters or issue-tree walkers. * * **Details** * * Every node has a `_tag` field for pattern-matching. The union includes both * terminal {@link Leaf} types and composite types that wrap inner issues: * {@link Filter}, {@link Encoding}, {@link Pointer}, {@link Composite}, * {@link AnyOf}. Use {@link makeFormatterDefault} when a human-readable * representation is needed. When parsing with `reportInput: true`, * value-bearing issues expose the rejected value through an enumerable `input` * field. Built-in formatters may include reported input in default messages. This * is not a general sanitization boundary: paths, ASTs, union successes, and * custom annotations or messages are preserved as supplied and remain the * caller's responsibility. * * @see {@link Leaf} — the terminal subset * @see {@link isIssue} — type guard * @see {@link hasInput} — checks whether an issue reports an input * @stability stable * @category models * @since 4.0.0 */ export type Issue = | Leaf // composite | Filter | Encoding | Pointer | Composite | AnyOf interface IssueNode { readonly [TypeId]: typeof TypeId /** * The input reported by the schema parser, when input reporting is enabled * and the issue is associated with a present value. */ readonly input?: unknown } class IssueNodeImpl implements IssueNode { readonly [TypeId] = TypeId /** * The input reported by the schema parser, when input reporting is enabled * and the issue is associated with a present value. */ declare readonly input?: unknown constructor(input?: unknown, options?: SchemaAST.ParseOptions) { if (options?.reportInput === true && input !== InternalParser.missing) { this.input = input } } } /** * Represents a schema issue produced when a schema filter (refinement check) fails. * * **When to use** * * Use when you need to inspect a schema issue that records which refinement * check rejected the value. * * **Details** * * - `filter` is the AST filter node that produced this issue. * - `issue` is the inner issue describing the failure reason. * * **Example** (Matching a Filter issue) * * ```ts import.meta.vitest * import { SchemaAST, SchemaIssue } from "effect" * * const formatIssue = SchemaIssue.makeFormatterDefault() * * function describe(issue: SchemaIssue.Issue): string { * if (issue._tag === "Filter") { * return `Filter failed: ${formatIssue(issue.issue)}` * } * return formatIssue(issue) * } * * const issue = new SchemaIssue.Filter( * SchemaAST.isPattern(/^valid$/), * new SchemaIssue.InvalidValue() * ) * describe(issue) // => `Filter failed: Expected a valid value` * ``` * * @see {@link Leaf} — terminal issue types that commonly appear as the inner `issue` * @see {@link CheckHook} — formatter hook for `Filter` issues * * @stability stable * @category models * @since 4.0.0 */ export interface Filter extends IssueNode { readonly _tag: "Filter" /** * The filter that failed. */ readonly filter: SchemaAST.Filter /** * The issue that occurred. */ readonly issue: Issue } /** * Constructs a schema issue for a failed refinement check. * * @stability stable * @category constructors * @since 4.0.0 */ export const Filter: new( /** * The filter that failed. */ filter: SchemaAST.Filter, /** * The issue that occurred. */ issue: Issue, /** * The present input associated with the issue. It is retained only when * `options.reportInput` is `true`. */ input?: unknown, /** * The effective parse options controlling input retention. */ options?: SchemaAST.ParseOptions ) => Filter = class extends IssueNodeImpl { readonly _tag = "Filter" /** * The filter that failed. */ readonly filter: SchemaAST.Filter /** * The issue that occurred. */ readonly issue: Issue constructor( /** * The filter that failed. */ filter: SchemaAST.Filter, /** * The issue that occurred. */ issue: Issue, /** * The present input associated with the issue. It is retained only when * `options.reportInput` is `true`. */ input?: unknown, /** * The effective parse options controlling input retention. */ options?: SchemaAST.ParseOptions ) { super(input, options) this.filter = filter this.issue = issue } } /** * Represents a schema issue produced when a schema transformation (encode/decode step) fails. * * **When to use** * * Use when you need to inspect failures from `Schema.decodeTo` / `Schema.encodeTo` * transformations. * * **Details** * * - `ast` is the AST node for the transformation that failed. * - `issue` is the inner issue describing the failure. * * @see {@link Filter} — failure from a refinement check (not a transformation) * @see {@link Composite} — multiple issues from a single schema node * * @stability stable * @category models * @since 4.0.0 */ export interface Encoding extends IssueNode { readonly _tag: "Encoding" /** * The schema that caused the issue. */ readonly ast: SchemaAST.AST /** * The issue that occurred. */ readonly issue: Issue } /** * Constructs a schema issue for a failed transformation. * * @stability stable * @category constructors * @since 4.0.0 */ export const Encoding: new( ast: SchemaAST.AST, issue: Issue, input?: unknown, options?: SchemaAST.ParseOptions ) => Encoding = class extends IssueNodeImpl { readonly _tag = "Encoding" /** * The schema that caused the issue. */ readonly ast: SchemaAST.AST /** * The issue that occurred. */ readonly issue: Issue constructor( /** * The schema that caused the issue. */ ast: SchemaAST.AST, /** * The issue that occurred. */ issue: Issue, /** * The present input associated with the issue. It is retained only when * `options.reportInput` is `true`. */ input?: unknown, /** * The effective parse options controlling input retention. */ options?: SchemaAST.ParseOptions ) { super(input, options) this.ast = ast this.issue = issue } } /** * Wraps an inner {@link Issue} with a property-key path, indicating *where* in * a nested structure the error occurred. * * **When to use** * * Use when you need to walk the issue tree to accumulate path segments for error * reporting. * * **Details** * * - `path` is an array of property keys (strings, numbers, or symbols). * - Formatters concatenate nested `Pointer` paths into a single path like * `["a"]["b"][0]`. * * @see {@link Composite} — groups multiple issues under one schema node * * @stability stable * @category models * @since 4.0.0 */ export interface Pointer extends IssueNode { readonly _tag: "Pointer" /** * The path to the location in the input that caused the issue. */ readonly path: ReadonlyArray /** * The issue that occurred. */ readonly issue: Issue } /** * Constructs a schema issue that points to a nested location. * * @stability stable * @category constructors * @since 4.0.0 */ export const Pointer: new(path: ReadonlyArray, issue: Issue) => Pointer = class extends IssueNodeImpl { readonly _tag = "Pointer" /** * The path to the location in the input that caused the issue. */ readonly path: ReadonlyArray /** * The issue that occurred. */ readonly issue: Issue constructor( /** * The path to the location in the input that caused the issue. */ path: ReadonlyArray, /** * The issue that occurred. */ issue: Issue ) { super() this.path = path this.issue = issue } } /** * Represents a schema issue produced when a required key or tuple index is missing from the input. * * **When to use** * * Use when you need to detect absent fields in struct/tuple validation. * * **Details** * * - `annotations` may contain a custom `messageMissingKey` for formatting. * * @see {@link Pointer} — wraps this issue with the missing key's path * @see {@link UnexpectedKey} — the opposite case (extra key present) * * @stability stable * @category models * @since 4.0.0 */ export interface MissingKey extends IssueNode { readonly _tag: "MissingKey" /** * The metadata for the issue. */ readonly annotations: Schema.Annotations.Key | undefined } /** * Constructs a schema issue for a missing key or tuple index. * * @stability stable * @category constructors * @since 4.0.0 */ export const MissingKey: new(annotations: Schema.Annotations.Key | undefined) => MissingKey = class extends IssueNodeImpl { readonly _tag = "MissingKey" /** * The metadata for the issue. */ readonly annotations: Schema.Annotations.Key | undefined constructor( /** * The metadata for the issue. */ annotations: Schema.Annotations.Key | undefined ) { super() this.annotations = annotations } } /** * Represents a schema issue produced when an input object or tuple contains a key/index not * declared by the schema. * * **When to use** * * Use when you need to detect excess properties during strict struct/tuple * validation. * * **Details** * * - `ast` is the schema that was being validated against. * - `annotations` on `ast` may contain a custom `messageUnexpectedKey`. * - The default formatter renders this as `"Expected no excess property"`, or * `"Unexpected key with value "` when the issue reports an input. * * @see {@link MissingKey} — the opposite case (required key absent) * @see {@link Pointer} — wraps this issue with the unexpected key's path * * @stability stable * @category models * @since 4.0.0 */ export interface UnexpectedKey extends IssueNode { readonly _tag: "UnexpectedKey" /** * The schema that caused the issue. */ readonly ast: SchemaAST.AST } /** * Constructs a schema issue for an unexpected key or tuple index. * * @stability stable * @category constructors * @since 4.0.0 */ export const UnexpectedKey: new( ast: SchemaAST.AST, input?: unknown, options?: SchemaAST.ParseOptions ) => UnexpectedKey = class extends IssueNodeImpl { readonly _tag = "UnexpectedKey" /** * The schema that caused the issue. */ readonly ast: SchemaAST.AST constructor( /** * The schema that caused the issue. */ ast: SchemaAST.AST, /** * The present input associated with the issue. It is retained only when * `options.reportInput` is `true`. */ input?: unknown, /** * The effective parse options controlling input retention. */ options?: SchemaAST.ParseOptions ) { super(input, options) this.ast = ast } } /** * Represents a schema issue that groups multiple child issues under a single schema node. * * **When to use** * * Use when you need to walk the issue tree for struct/tuple schemas that collect * all field errors rather than failing on the first. * * **Details** * * - `issues` is a non-empty readonly array (at least one child). * - Formatters flatten `Composite` by recursing into each child. * * @see {@link AnyOf} — used for union no-match errors (similar but different semantics) * @see {@link Pointer} — adds path context to individual issues * * @stability stable * @category models * @since 4.0.0 */ export interface Composite extends IssueNode { readonly _tag: "Composite" /** * The schema that caused the issue. */ readonly ast: SchemaAST.AST /** * The issues that occurred. */ readonly issues: readonly [Issue, ...Array] } /** * Constructs a schema issue that groups multiple child issues. * * @stability stable * @category constructors * @since 4.0.0 */ export const Composite: new( ast: SchemaAST.AST, issues: readonly [Issue, ...Array], input?: unknown, options?: SchemaAST.ParseOptions ) => Composite = class extends IssueNodeImpl { readonly _tag = "Composite" /** * The schema that caused the issue. */ readonly ast: SchemaAST.AST /** * The issues that occurred. */ readonly issues: readonly [Issue, ...Array] constructor( /** * The schema that caused the issue. */ ast: SchemaAST.AST, /** * The issues that occurred. */ issues: readonly [Issue, ...Array], /** * The present input associated with the issue. It is retained only when * `options.reportInput` is `true`. */ input?: unknown, /** * The effective parse options controlling input retention. */ options?: SchemaAST.ParseOptions ) { super(input, options) this.ast = ast this.issues = issues } } /** * Represents a schema issue produced when the runtime type of the input does not match the type * expected by the schema. * * **When to use** * * Use when you need to detect basic type mismatches, such as a wrong primitive * or `null` where an object was expected. * * **Details** * * - `ast` is the schema node that expected a different type. * - The default formatter renders this as `"Expected "`, adding * `", got "` when the issue reports an input. * * **Example** (Formatting a type mismatch) * * ```ts import.meta.vitest * import { Schema, SchemaIssue } from "effect" * * const formatIssue = SchemaIssue.makeFormatterDefault() * const issue = new SchemaIssue.InvalidType(Schema.String.ast) * formatIssue(issue) // => "Expected string" * ``` * * @see {@link InvalidValue} — the input has the right type but fails a value constraint * * @stability stable * @category models * @since 4.0.0 */ export interface InvalidType extends IssueNode { readonly _tag: "InvalidType" /** * The schema that caused the issue. */ readonly ast: SchemaAST.AST } /** * Constructs a schema issue for an input with an invalid runtime type. * * @stability stable * @category constructors * @since 4.0.0 */ export const InvalidType: new( ast: SchemaAST.AST, input?: unknown, options?: SchemaAST.ParseOptions ) => InvalidType = class extends IssueNodeImpl { readonly _tag = "InvalidType" /** * The schema that caused the issue. */ readonly ast: SchemaAST.AST constructor( /** * The schema that caused the issue. */ ast: SchemaAST.AST, /** * The present input associated with the issue. It is retained only when * `options.reportInput` is `true`. */ input?: unknown, /** * The effective parse options controlling input retention. */ options?: SchemaAST.ParseOptions ) { super(input, options) this.ast = ast } } /** * Represents a schema issue produced when the input has the correct type but its value violates a * constraint (e.g. a string that is too short, a number out of range). * * **When to use** * * Use when you need to detect constraint violations from `Schema.check` with * `Schema.isMinLength`, `Schema.isGreaterThan`, or similar checks. * * **Details** * * - A `message` annotation is returned unchanged and takes precedence over all * other default formatting. * - Without `message`, an `expected` annotation is formatted as * `"Expected "`, adding `", got "` when input is reported. * - Without either annotation, the default formatter renders * `"Expected a valid value"`, or `"Invalid data "` when input is * reported. * * **Example** (Returning InvalidValue from a custom filter) * * ```ts import.meta.vitest * import { SchemaIssue } from "effect" * * const formatIssue = SchemaIssue.makeFormatterDefault() * const issue = new SchemaIssue.InvalidValue({ message: "must not be empty" }) * formatIssue(issue) // => "must not be empty" * ``` * * @see {@link InvalidType} — the input has the wrong type entirely * @see {@link Filter} — composite wrapper when a schema filter produces this issue * * @stability stable * @category models * @since 4.0.0 */ export interface InvalidValue extends IssueNode { readonly _tag: "InvalidValue" /** * The metadata for the issue. */ readonly annotations: Schema.Annotations.Issue | undefined } /** * Constructs a schema issue for a value that violates a constraint. * * @stability stable * @category constructors * @since 4.0.0 */ export const InvalidValue: new( annotations?: Schema.Annotations.Issue | undefined, input?: unknown, options?: SchemaAST.ParseOptions ) => InvalidValue = class extends IssueNodeImpl { readonly _tag = "InvalidValue" /** * The metadata for the issue. */ readonly annotations: Schema.Annotations.Issue | undefined constructor( /** * The metadata for the issue. */ annotations?: Schema.Annotations.Issue | undefined, /** * The present input associated with the issue. It is retained only when * `options.reportInput` is `true`. */ input?: unknown, /** * The effective parse options controlling input retention. */ options?: SchemaAST.ParseOptions ) { super(input, options) this.annotations = annotations } } /** @internal */ export function makeCompositeAtKey( compositeAst: SchemaAST.AST, pointerKey: PropertyKey, pointerIssue: Issue, compositeInput: unknown, parseOptions?: SchemaAST.ParseOptions ): Composite { return new Composite( compositeAst, [new Pointer([pointerKey], pointerIssue)], compositeInput, parseOptions ) } /** * Represents a schema issue produced when a forbidden operation is encountered during parsing, * such as an asynchronous Effect running inside `Schema.decodeUnknownSync`. * * **When to use** * * Use when you need to detect that a schema requires async execution but was run * synchronously. * * **Details** * * - `annotations` optionally carries a `message` string. * - The default formatter renders this as `"Forbidden operation"`. * * **Example** (Creating a Forbidden issue) * * ```ts import.meta.vitest * import { SchemaIssue } from "effect" * * const formatIssue = SchemaIssue.makeFormatterDefault() * const issue = new SchemaIssue.Forbidden( * { message: "async operation not allowed in sync context" } * ) * formatIssue(issue) // => "async operation not allowed in sync context" * ``` * * @see {@link InvalidValue} — for value-constraint failures (not operation failures) * * @stability stable * @category models * @since 4.0.0 */ export interface Forbidden extends IssueNode { readonly _tag: "Forbidden" /** * The metadata for the issue. */ readonly annotations: Schema.Annotations.Issue | undefined } /** * Constructs a schema issue for a forbidden parsing operation. * * @stability stable * @category constructors * @since 4.0.0 */ export const Forbidden: new( annotations: Schema.Annotations.Issue | undefined, input?: unknown, options?: SchemaAST.ParseOptions ) => Forbidden = class extends IssueNodeImpl { readonly _tag = "Forbidden" /** * The metadata for the issue. */ readonly annotations: Schema.Annotations.Issue | undefined constructor( /** * The metadata for the issue. */ annotations: Schema.Annotations.Issue | undefined, /** * The present input associated with the issue. It is retained only when * `options.reportInput` is `true`. */ input?: unknown, /** * The effective parse options controlling input retention. */ options?: SchemaAST.ParseOptions ) { super(input, options) this.annotations = annotations } } /** * Represents a schema issue produced when a value does not match *any* member of a union schema. * * **When to use** * * Use when you need to inspect which union members were attempted and why each * failed. * * **Details** * * - `ast` is the `Union` AST node. * - `issues` contains the per-member failures. * * **Gotchas** * * `issues` is empty when no union member was applicable. In that case, the * default formatter reports the expected type for the union and appends * `", got "` when input is reported. * * @see {@link OneOf} — the opposite: *too many* members matched * @see {@link Composite} — groups multiple issues under a non-union schema * * @stability stable * @category models * @since 4.0.0 */ export interface AnyOf extends IssueNode { readonly _tag: "AnyOf" /** * The schema that caused the issue. */ readonly ast: SchemaAST.Union /** * The issues that occurred. */ readonly issues: ReadonlyArray } /** * Constructs a schema issue for a value that matches no union member. * * @stability stable * @category constructors * @since 4.0.0 */ export const AnyOf: new( ast: SchemaAST.Union, issues: ReadonlyArray, input?: unknown, options?: SchemaAST.ParseOptions ) => AnyOf = class extends IssueNodeImpl { readonly _tag = "AnyOf" /** * The schema that caused the issue. */ readonly ast: SchemaAST.Union /** * The issues that occurred. */ readonly issues: ReadonlyArray constructor( /** * The schema that caused the issue. */ ast: SchemaAST.Union, /** * The issues that occurred. */ issues: ReadonlyArray, /** * The present input associated with the issue. It is retained only when * `options.reportInput` is `true`. */ input?: unknown, /** * The effective parse options controlling input retention. */ options?: SchemaAST.ParseOptions ) { super(input, options) this.ast = ast this.issues = issues } } /** * Represents a schema issue produced when a value matches *multiple* members of a union that is * configured to allow exactly one match (oneOf mode). * * **When to use** * * Use when you need to detect ambiguous union matches when `oneOf` validation is * enabled. * * **Details** * * - `ast` is the `Union` AST node. * - `successes` lists the AST nodes of each member that accepted the input. * - The default formatter renders this as * `"Expected exactly one member to match"`, or * `"Expected exactly one member to match the input "` when input is * reported. * * @see {@link AnyOf} — the opposite: *no* members matched * * @stability stable * @category models * @since 4.0.0 */ export interface OneOf extends IssueNode { readonly _tag: "OneOf" /** * The schema that caused the issue. */ readonly ast: SchemaAST.Union /** * The schemas that were successful. */ readonly successes: ReadonlyArray } /** * Constructs a schema issue for a value that matches multiple union members. * * @stability stable * @category constructors * @since 4.0.0 */ export const OneOf: new( ast: SchemaAST.Union, successes: ReadonlyArray, input?: unknown, options?: SchemaAST.ParseOptions ) => OneOf = class extends IssueNodeImpl { readonly _tag = "OneOf" /** * The schema that caused the issue. */ readonly ast: SchemaAST.Union /** * The schemas that were successful. */ readonly successes: ReadonlyArray constructor( /** * The schema that caused the issue. */ ast: SchemaAST.Union, /** * The schemas that were successful. */ successes: ReadonlyArray, /** * The present input associated with the issue. It is retained only when * `options.reportInput` is `true`. */ input?: unknown, /** * The effective parse options controlling input retention. */ options?: SchemaAST.ParseOptions ) { super(input, options) this.ast = ast this.successes = successes } } function makeFilterIssue( entry: Schema.FilterIssue, input?: unknown, options?: SchemaAST.ParseOptions ): Issue { if (isIssue(entry)) { return entry } if (typeof entry === "string") { return new InvalidValue({ message: entry }, input, options) } const inner = typeof entry.issue === "string" ? new InvalidValue({ message: entry.issue }, input, options) : entry.issue return new Pointer(entry.path, inner) } /** @internal */ export function makeSingle( out: undefined | boolean | Schema.FilterIssue, input?: unknown, options?: SchemaAST.ParseOptions ): Issue | undefined { if (out === undefined) { return undefined } if (typeof out === "boolean") { return out ? undefined : new InvalidValue(undefined, input, options) } return makeFilterIssue(out, input, options) } /** @internal */ export function normalizeFilterOutput( ast: SchemaAST.AST, out: Schema.FilterOutput, input?: unknown, options?: SchemaAST.ParseOptions ): Issue | undefined { if (Array.isArray(out)) { if (!Arr.isReadonlyArrayNonEmpty(out)) { return undefined } return out.length === 1 ? makeFilterIssue(out[0], input, options) : new Composite(ast, Arr.map(out, (entry) => makeFilterIssue(entry, input, options)), input, options) } return makeSingle(out as undefined | boolean | Schema.FilterIssue, input, options) } /** * A function type that converts an {@link Issue} into a formatted * representation. Specialisation of the generic `Formatter` from * `Formatter.ts` with `Value` fixed to `Issue`. * * @see {@link makeFormatterDefault} — creates a `Formatter` * @see {@link makeFormatterStandardSchemaV1} — creates a `Formatter` * * @stability stable * @category formatting * @since 4.0.0 */ export interface Formatter extends FormatterI {} /** * Callback type used to format {@link Leaf} issues into strings. * * **When to use** * * Use when customizing how {@link makeFormatterStandardSchemaV1} renders * terminal issues. * * @see {@link defaultLeafHook} — the built-in implementation * @see {@link Leaf} — the union of terminal issue types * * @stability stable * @category formatting * @since 4.0.0 */ export type LeafHook = (issue: Leaf) => string /** * Returns the built-in {@link LeafHook} used by default formatters. * * **When to use** * * Use as the default leaf renderer when customizing only the {@link CheckHook}. * * **Details** * * - Checks for a `message` annotation first; returns it if present. * - For `InvalidValue`, an `expected` annotation uses the standard expected * value message and includes reported input when available. * - Otherwise generates a default message per `_tag`. When the issue reports * input, the message includes its formatted value where applicable: * - `InvalidType` → `"Expected "` or `"Expected , got "` * - `InvalidValue` → `"Expected a valid value"` or `"Invalid data "` * - `MissingKey` → `"Missing key"` * - `UnexpectedKey` → `"Expected no excess property"` or * `"Unexpected key with value "` * - `Forbidden` → `"Forbidden operation"` * - `OneOf` → `"Expected exactly one member to match"` or * `"Expected exactly one member to match the input "` * * **Example** (Formatting Standard Schema issues with defaultLeafHook) * * ```ts import.meta.vitest * import { SchemaIssue } from "effect" * * const formatter = SchemaIssue.makeFormatterStandardSchemaV1({ * leafHook: SchemaIssue.defaultLeafHook * }) * formatter(new SchemaIssue.MissingKey(undefined)) // => { issues: [{ path: [], message: "Missing key" }] } * ``` * * @see {@link LeafHook} * @see {@link makeFormatterStandardSchemaV1} * * @stability stable * @category formatting * @since 4.0.0 */ export const defaultLeafHook: LeafHook = (issue): string => { const message = findMessage(issue) if (message !== undefined) return message switch (issue._tag) { case "InvalidType": return getExpectedMessage(InternalAnnotations.getExpected(issue.ast), issue) case "InvalidValue": { const expected = findExpected(issue) if (expected !== undefined) return getExpectedMessage(expected, issue) const input = formatInput(issue) return input === undefined ? "Expected a valid value" : `Invalid data ${input}` } case "MissingKey": return "Missing key" case "UnexpectedKey": { const input = formatInput(issue) return input === undefined ? "Expected no excess property" : `Unexpected key with value ${input}` } case "Forbidden": return "Forbidden operation" case "OneOf": { const input = formatInput(issue) return input === undefined ? "Expected exactly one member to match" : `Expected exactly one member to match the input ${input}` } } } /** * Callback type used to format {@link Filter} issues into strings. * * **When to use** * * Use when customizing how {@link makeFormatterStandardSchemaV1} renders * filter failures. * * **Details** * * - Returns `string` to override the message, or `undefined` to fall back to * the default formatting. * * @see {@link defaultCheckHook} — the built-in implementation * @see {@link Filter} — the issue type this hook formats * * @stability stable * @category formatting * @since 4.0.0 */ export type CheckHook = (issue: Filter) => string | undefined /** * Returns the built-in {@link CheckHook} used by default formatters. * * **When to use** * * Use as the default filter renderer when customizing only the {@link LeafHook}. * * **Details** * * - Looks for a `message` annotation on the filter first, then on the inner * issue. * - Returns `undefined` when no annotation is found, causing the formatter to * fall back to `"Expected "` or, when the filter reports input, * `"Expected , got "`. * * @see {@link CheckHook} * @see {@link makeFormatterStandardSchemaV1} * * @stability stable * @category formatting * @since 4.0.0 */ export const defaultCheckHook: CheckHook = (issue): string | undefined => findMessage(issue) ?? findMessage(issue.issue) /** * Creates a {@link Formatter} that produces a `StandardSchemaV1.FailureResult`. * * **When to use** * * Use when you need schema parse errors in * [Standard Schema V1](https://github.com/standard-schema/standard-schema) * format, optionally customizing leaf or check issue rendering. * * **Details** * * - Returns a `Formatter`. * - Each leaf issue is flattened into `{ message, path }` entries. * - `Pointer` paths are accumulated to produce full property paths. * - Falls back to {@link defaultLeafHook} / {@link defaultCheckHook} when no * hooks are provided. * - Default messages include reported input when the issue that produces the * message has an `input` field. The returned Standard Schema issues do not * receive an `input` field. * * **Gotchas** * * Reported input can appear inside the Standard Schema `message` string even * though it is not exposed as a separate property. Custom hooks control their * complete message and are not modified. * * **Example** (Creating a Standard Schema V1 formatter) * * ```ts import.meta.vitest * import { SchemaIssue } from "effect" * * const formatter = SchemaIssue.makeFormatterStandardSchemaV1() * formatter(new SchemaIssue.MissingKey(undefined)).issues[0].message // => "Missing key" * ``` * * @see {@link makeFormatterDefault} — produces a plain string instead * @see {@link LeafHook} * @see {@link CheckHook} * * @stability stable * @category formatting * @since 4.0.0 */ export function makeFormatterStandardSchemaV1(options?: { readonly leafHook?: LeafHook | undefined readonly checkHook?: CheckHook | undefined }): Formatter { return (issue) => ({ issues: toDefaultIssues(issue, [], options?.leafHook ?? defaultLeafHook, options?.checkHook ?? defaultCheckHook) }) } // A subtype of StandardSchemaV1.Issue type DefaultIssue = { readonly message: string readonly path: ReadonlyArray } function formatInput(issue: Issue): string | undefined { return hasInput(issue) ? format(issue.input) : undefined } function findExpected(issue: InvalidValue): string | undefined { const expected = issue.annotations?.expected return typeof expected === "string" ? expected : undefined } function getExpectedMessage(expected: string, issue: Issue): string { const input = formatInput(issue) return input === undefined ? `Expected ${expected}` : `Expected ${expected}, got ${input}` } function getFilterMessage(issue: Filter, checkHook: CheckHook): string | undefined { const message = checkHook(issue) if (message !== undefined || issue.issue._tag !== "InvalidValue") return message const expected = findExpected(issue.issue) return expected === undefined ? getExpectedMessage(formatCheck(issue.filter), issue) : getExpectedMessage(expected, issue.issue) } function toDefaultIssues( issue: Issue, path: ReadonlyArray, leafHook: LeafHook, checkHook: CheckHook, messageOverride?: string ): Array { switch (issue._tag) { case "Filter": return toDefaultIssues( issue.issue, path, leafHook, checkHook, messageOverride ?? getFilterMessage(issue, checkHook) ) case "Encoding": return toDefaultIssues(issue.issue, path, leafHook, checkHook, messageOverride) case "Pointer": return toDefaultIssues(issue.issue, [...path, ...issue.path], leafHook, checkHook, messageOverride) case "Composite": case "AnyOf": { if (issue._tag === "Composite" || issue.issues.length > 0) { return issue.issues.flatMap((issue) => toDefaultIssues(issue, path, leafHook, checkHook, messageOverride)) } return [{ path, message: messageOverride ?? findMessage(issue) ?? getExpectedMessage(InternalAnnotations.getExpected(issue.ast), issue) }] } default: return [{ path, message: messageOverride ?? leafHook(issue) }] } } function formatCheck(check: SchemaAST.Check): string { const expected = check.annotations?.expected if (typeof expected === "string") return expected switch (check._tag) { case "Filter": return "" case "FilterGroup": return check.checks.map((check) => formatCheck(check)).join(" & ") } } /** * Creates a {@link Formatter} that converts an {@link Issue} into a * human-readable multi-line string. * * **When to use** * * Use when you need to format a `SchemaIssue.Issue` as error messages for * logging, CLI output, or developer-facing diagnostics. * * **Details** * * - Flattens the issue tree into `{ message, path }` entries using * {@link defaultLeafHook} and {@link defaultCheckHook}. * - Includes reported input in default messages when the node producing the * message has an `input` field. * - Each entry is rendered as `""` or `"\n at "`. * - Multiple entries are joined with newlines. * * **Gotchas** * * Formatting an issue can disclose input retained with `reportInput: true`. * Wrapper inputs are not inherited by child messages, and custom messages are * returned unchanged. * * **Example** (Formatting an issue as a string) * * ```ts import.meta.vitest * import { SchemaIssue } from "effect" * * const formatter = SchemaIssue.makeFormatterDefault() * formatter(new SchemaIssue.MissingKey(undefined)) // => "Missing key" * ``` * * @see {@link makeFormatterStandardSchemaV1} — produces Standard Schema V1 format instead * @see {@link Formatter} * * @stability stable * @category formatting * @since 4.0.0 */ export function makeFormatterDefault(): Formatter { return (issue) => formatIssue(issue, "") } /** @internal */ export const defaultFormatter = makeFormatterDefault() function formatIssue(issue: Issue, path: string, messageOverride?: string): string { let message: string switch (issue._tag) { case "Filter": return formatIssue(issue.issue, path, messageOverride ?? getFilterMessage(issue, defaultCheckHook)) case "Encoding": return formatIssue(issue.issue, path, messageOverride) case "Pointer": return formatIssue(issue.issue, path + formatPath(issue.path), messageOverride) case "Composite": case "AnyOf": { if (issue._tag === "Composite" || issue.issues.length > 0) { return issue.issues.map((issue) => formatIssue(issue, path, messageOverride)).join("\n") } message = messageOverride ?? findMessage(issue) ?? getExpectedMessage(InternalAnnotations.getExpected(issue.ast), issue) break } default: message = messageOverride ?? defaultLeafHook(issue) break } return path ? `${message}\n at ${path}` : message } function findMessage(issue: Issue): string | undefined { // A Composite's ast describes the whole schema, not the failures it groups. if (issue._tag === "Pointer" || issue._tag === "Composite") return if (issue._tag === "Encoding") return findMessage(issue.issue) const annotations = issue._tag === "Filter" ? issue.filter.annotations : "annotations" in issue ? issue.annotations : issue.ast.annotations const message = annotations?.[ issue._tag === "MissingKey" ? "messageMissingKey" : issue._tag === "UnexpectedKey" ? "messageUnexpectedKey" : "message" ] if (typeof message === "string") return message }