已发布 上游基线 bf46254 原文 ↗ 在 GitHub 编辑

错误消息

定制并强化 schema 解码的错误消息:默认消息、细化消息与自定义消息。

默认错误消息

默认情况下,当解析出错时,系统会根据 schema 的结构和错误的性质自动生成一条信息丰富的消息(更多信息见 TreeFormatter)。 例如,当必需的属性缺失、或数据类型不匹配时,错误消息会清楚地说明期望值与实际输入之间的差异。

示例(类型不匹配)

import { Schema } from "effect"

const Person = Schema.Struct({
  name: Schema.String,
  age: Schema.Number,
})

Schema.decodeUnknownSync(Person)(null)
// Output: ParseError: Expected { readonly name: string; readonly age: number }, actual null

示例(缺少属性)

import { Schema } from "effect"

const Person = Schema.Struct({
  name: Schema.String,
  age: Schema.Number,
})

Schema.decodeUnknownSync(Person)({}, { errors: "all" })
/*
throws:
ParseError: { readonly name: string; readonly age: number }
├─ ["name"]
│  └─ is missing
└─ ["age"]
   └─ is missing
*/

示例(属性类型不正确)

import { Schema } from "effect"

const Person = Schema.Struct({
  name: Schema.String,
  age: Schema.Number,
})

Schema.decodeUnknownSync(Person)({ name: null, age: "age" }, { errors: "all" })
/*
throws:
ParseError: { readonly name: string; readonly age: number }
├─ ["name"]
│  └─ Expected string, actual null
└─ ["age"]
   └─ Expected number, actual "age"
*/

用标识符让错误消息更清楚

当 schema 有多个字段或嵌套结构时,默认的错误消息可能变得过于复杂和冗长。 为此,你可以利用 identifiertitledescription 这类注解,让这些消息更清晰、更简洁。

示例(用标识符提升可读性)

import { Schema } from "effect"

const Name = Schema.String.annotations({ identifier: "Name" })

const Age = Schema.Number.annotations({ identifier: "Age" })

const Person = Schema.Struct({
  name: Name,
  age: Age,
}).annotations({ identifier: "Person" })

Schema.decodeUnknownSync(Person)(null)
/*
throws:
ParseError: Expected Person, actual null
*/

Schema.decodeUnknownSync(Person)({}, { errors: "all" })
/*
throws:
ParseError: Person
├─ ["name"]
│  └─ is missing
└─ ["age"]
   └─ is missing
*/

Schema.decodeUnknownSync(Person)({ name: null, age: null }, { errors: "all" })
/*
throws:
ParseError: Person
├─ ["name"]
│  └─ Expected Name, actual null
└─ ["age"]
   └─ Expected Age, actual null
*/

细化(Refinement)

当细化失败时,默认的错误消息会指出失败发生在 “from” 部分,还是发生在定义该细化的谓词内部:

示例(细化错误)

import { Schema } from "effect"

const Name = Schema.NonEmptyString.annotations({ identifier: "Name" })

const Age = Schema.Positive.pipe(Schema.int({ identifier: "Age" }))

const Person = Schema.Struct({
  name: Name,
  age: Age,
}).annotations({ identifier: "Person" })

// From side failure
Schema.decodeUnknownSync(Person)({ name: null, age: 18 })
/*
throws:
ParseError: Person
└─ ["name"]
   └─ Name
      └─ From side refinement failure
         └─ Expected string, actual null
*/

// Predicate refinement failure
Schema.decodeUnknownSync(Person)({ name: "", age: 18 })
/*
throws:
ParseError: Person
└─ ["name"]
   └─ Name
      └─ Predicate refinement failure
         └─ Expected a non empty string, actual ""
*/

在第一个示例中,错误消息指出 name 属性发生了 “from 侧” 细化失败,并说明期望 string 却收到了 null。 在第二个示例中,报告的是 “谓词” 细化失败,说明 name 期望非空字符串,但提供的却是空字符串。

变换(Transformation)

在不同类型或格式之间做变换时偶尔也会产生错误。 系统会提供结构化的错误消息来指明错误发生的位置:

  • 编码侧失败(Encoded Side Failure): 这一侧的错误通常表示变换的输入不符合期望的初始类型或格式。例如期望 string 却收到 null
  • 变换过程失败(Transformation Process Failure): 这类错误在变换逻辑本身失败时出现,例如输入不满足变换函数中指定的条件。
  • 类型侧失败(Type Side Failure): 当变换的输出不满足解码侧 schema 的要求时出现。如果变换后的值未通过后续校验或条件,就会发生这种情况。

示例(变换错误)

import { ParseResult, Schema } from "effect"

const schema = Schema.transformOrFail(
  Schema.String,
  Schema.String.pipe(Schema.minLength(2)),
  {
    strict: true,
    decode: (s, _, ast) =>
      s.length > 0
        ? ParseResult.succeed(s)
        : ParseResult.fail(new ParseResult.Type(ast, s)),
    encode: ParseResult.succeed,
  },
)

// Encoded side failure
Schema.decodeUnknownSync(schema)(null)
/*
throws:
ParseError: (string <-> minLength(2))
└─ Encoded side transformation failure
   └─ Expected string, actual null
*/

// transformation failure
Schema.decodeUnknownSync(schema)("")
/*
throws:
ParseError: (string <-> minLength(2))
└─ Transformation process failure
   └─ Expected (string <-> minLength(2)), actual ""
*/

// Type side failure
Schema.decodeUnknownSync(schema)("a")
/*
throws:
ParseError: (string <-> minLength(2))
└─ Type side transformation failure
   └─ minLength(2)
      └─ Predicate refinement failure
         └─ Expected a string at least 2 character(s) long, actual "a"
*/

自定义错误消息

你可以使用 message 注解,为 schema 的不同部分量身定制专门的自定义错误消息。 这让开发者能够提供更贴合具体上下文的反馈,从而改进调试与校验过程。

下面概述了 MessageAnnotation 类型,你可以用它来构造这些消息:

type MessageAnnotation = (issue: ParseIssue) =>
  | string
  | Effect<string>
  | {
      readonly message: string | Effect<string>
      readonly override: boolean
    }
返回类型说明
string提供一条静态消息,直接描述该错误。
Effect<string>使用动态消息,可以结合同步过程的结果,或依赖可选的依赖项。
Object(带 messageoverride允许你定义一条特定的错误消息,并配上一个布尔标志(override)。该标志决定这条自定义消息是否应取代任何默认消息或嵌套的自定义消息,从而让展示给用户的错误输出得到精确控制。

示例(给 string schema 添加自定义错误消息)

import { Schema } from "effect"

// Define a string schema without a custom message
const MyString = Schema.String

// Attempt to decode `null`, resulting in a default error message
Schema.decodeUnknownSync(MyString)(null)
/*
throws:
ParseError: Expected string, actual null
*/

// Define a string schema with a custom error message
const MyStringWithMessage = Schema.String.annotations({
  message: () => "not a string",
})

// Decode with the custom schema, showing the new error message
Schema.decodeUnknownSync(MyStringWithMessage)(null)
/*
throws:
ParseError: not a string
*/

示例(带 override 选项的联合 schema 自定义错误消息)

import { Schema } from "effect"

// Define a union schema without a custom message
const MyUnion = Schema.Union(Schema.String, Schema.Number)

// Decode `null`, resulting in default union error messages
Schema.decodeUnknownSync(MyUnion)(null)
/*
throws:
ParseError: string | number
├─ Expected string, actual null
└─ Expected number, actual null
*/

// Define a union schema with a custom message and override flag
const MyUnionWithMessage = Schema.Union(
  Schema.String,
  Schema.Number,
).annotations({
  message: () => ({
    message: "Please provide a string or a number",
    // Ensures this message replaces all nested messages
    override: true,
  }),
})

// Decode with the custom schema, showing the new error message
Schema.decodeUnknownSync(MyUnionWithMessage)(null)
/*
throws:
ParseError: Please provide a string or a number
*/

消息的通用准则

确定消息时遵循的一般逻辑如下:

  1. 如果没有设置任何自定义消息,则使用与操作(即解码或编码)失败所在的最内层 schema 相关的默认消息。

  2. 如果设置了自定义消息,则从最内层 schema 到最外层,使用对应第一个失败 schema 的消息。不过,如果失败的 schema 没有自定义消息,那么将使用默认消息

  3. 作为一项可选启用的特性,你可以通过把 override 标志设为 true覆盖准则 2。这会让该自定义消息优先于来自内层 schema 的所有其他自定义消息。这样做是为了应对这样的场景:用户想定义一条单一的、累积性的自定义消息,用来描述一个有效值必须具有哪些属性,并且不希望看到默认消息。

下面来看一些实际示例。

标量 schema

示例(标量 schema 的简单自定义消息)

import { Schema } from "effect"

const MyString = Schema.String.annotations({
  message: () => "my custom message",
})

const decode = Schema.decodeUnknownSync(MyString)

try {
  decode(null)
} catch (e: any) {
  console.log(e.message) // "my custom message"
}

细化(Refinement)

这个示例演示了如何在细化链的最后一个细化上设置自定义消息。可以看到,只有当与 maxLength 相关的细化失败时才会使用这条自定义消息;否则会使用默认消息。

示例(给链中最后一个细化设置自定义消息)

import { Schema } from "effect"

const MyString = Schema.String.pipe(
  Schema.minLength(1),
  Schema.maxLength(2),
).annotations({
  // This message is displayed only if the last filter (`maxLength`) fails
  message: () => "my custom message",
})

const decode = Schema.decodeUnknownSync(MyString)

try {
  decode(null)
} catch (e: any) {
  console.log(e.message)
  /*
   minLength(1) & maxLength(2)
   └─ From side refinement failure
      └─ minLength(1)
         └─ From side refinement failure
            └─ Expected string, actual null
  */
}

try {
  decode("")
} catch (e: any) {
  console.log(e.message)
  /*
   minLength(1) & maxLength(2)
   └─ From side refinement failure
      └─ minLength(1)
         └─ Predicate refinement failure
            └─ Expected a string at least 1 character(s) long, actual ""
  */
}

try {
  decode("abc")
} catch (e: any) {
  console.log(e.message)
  // "my custom message"
}

当设置了多条自定义消息时,从最内层细化到最外层,使用对应第一个失败谓词的那条消息:

示例(多个细化的自定义消息)

import { Schema } from "effect"

const MyString = Schema.String
  // This message is displayed only if a non-String is passed as input
  .annotations({ message: () => "String custom message" })
  .pipe(
    // This message is displayed only if the filter `minLength` fails
    Schema.minLength(1, { message: () => "minLength custom message" }),
    // This message is displayed only if the filter `maxLength` fails
    Schema.maxLength(2, { message: () => "maxLength custom message" }),
  )

const decode = Schema.decodeUnknownSync(MyString)

try {
  decode(null)
} catch (e: any) {
  console.log(e.message) // String custom message
}

try {
  decode("")
} catch (e: any) {
  console.log(e.message) // minLength custom message
}

try {
  decode("abc")
} catch (e: any) {
  console.log(e.message) // maxLength custom message
}

你也可以通过把 override 标志设为 true 来改变默认行为。当你想要创建一条单一而全面的自定义消息,用来描述一个有效值必须具备的属性,并且不希望显示默认消息时,这很有用。

示例(覆盖默认消息)

import { Schema } from "effect"

const MyString = Schema.String.pipe(
  Schema.minLength(1),
  Schema.maxLength(2),
).annotations({
  // By setting the `override` flag to `true`, this message will always be shown for any error
  message: () => ({ message: "my custom message", override: true }),
})

const decode = Schema.decodeUnknownSync(MyString)

try {
  decode(null)
} catch (e: any) {
  console.log(e.message) // my custom message
}

try {
  decode("")
} catch (e: any) {
  console.log(e.message) // my custom message
}

try {
  decode("abc")
} catch (e: any) {
  console.log(e.message) // my custom message
}

变换

在下面的例子里,IntFromString 是一个把字符串转成整数的变换 schema。它会根据不同的场景应用特定的校验消息。

示例(字符串转整数的自定义错误消息)

import { ParseResult, Schema } from "effect"

const IntFromString = Schema.transformOrFail(
  // This message is displayed only if the input is not a string
  Schema.String.annotations({ message: () => "please enter a string" }),
  // This message is displayed only if the input can be converted
  // to a number but it's not an integer
  Schema.Int.annotations({ message: () => "please enter an integer" }),
  {
    strict: true,
    decode: (s, _, ast) => {
      const n = Number(s)
      return Number.isNaN(n)
        ? ParseResult.fail(new ParseResult.Type(ast, s))
        : ParseResult.succeed(n)
    },
    encode: (n) => ParseResult.succeed(String(n)),
  },
)
  // This message is displayed only if the input
  // cannot be converted to a number
  .annotations({ message: () => "please enter a parseable string" })

const decode = Schema.decodeUnknownSync(IntFromString)

try {
  decode(null)
} catch (e: any) {
  console.log(e.message) // please enter a string
}

try {
  decode("1.2")
} catch (e: any) {
  console.log(e.message) // please enter an integer
}

try {
  decode("not a number")
} catch (e: any) {
  console.log(e.message) // please enter a parseable string
}

复合 schema

stringnumber 这类简单的标量值不同,自定义消息系统在处理复杂 schema 时格外好用。例如,设想一个由嵌套结构组成的 schema:一个 struct 里包含一个由其它 struct 组成的数组。下面我们通过一个例子来看看,在处理这类嵌套结构中的解码错误时,默认消息能带来什么优势:

示例(嵌套 schema 中的自定义错误消息)

import { Schema, pipe } from "effect"

const schema = Schema.Struct({
  outcomes: pipe(
    Schema.Array(
      Schema.Struct({
        id: Schema.String,
        text: pipe(
          Schema.String.annotations({
            message: () => "error_invalid_outcome_type",
          }),
          Schema.minLength(1, { message: () => "error_required_field" }),
          Schema.maxLength(50, {
            message: () => "error_max_length_field",
          }),
        ),
      }),
    ),
    Schema.minItems(1, { message: () => "error_min_length_field" }),
  ),
})

Schema.decodeUnknownSync(schema, { errors: "all" })({
  outcomes: [],
})
/*
throws
ParseError: { readonly outcomes: minItems(1) }
└─ ["outcomes"]
   └─ error_min_length_field
*/

Schema.decodeUnknownSync(schema, { errors: "all" })({
  outcomes: [
    { id: "1", text: "" },
    { id: "2", text: "this one is valid" },
    { id: "3", text: "1234567890".repeat(6) },
  ],
})
/*
throws
ParseError: { readonly outcomes: minItems(1) }
└─ ["outcomes"]
   └─ minItems(1)
      └─ From side refinement failure
         └─ ReadonlyArray<{ readonly id: string; readonly text: minLength(1) & maxLength(50) }>
            ├─ [0]
            │  └─ { readonly id: string; readonly text: minLength(1) & maxLength(50) }
            │     └─ ["text"]
            │        └─ error_required_field
            └─ [2]
               └─ { readonly id: string; readonly text: minLength(1) & maxLength(50) }
                  └─ ["text"]
                     └─ error_max_length_field
*/

基于 Effect 的消息

错误消息并不局限于简单的字符串:通过返回一个 Effect,它们可以访问依赖,例如一个国际化服务。这种方式让消息能够根据外部上下文或服务动态调整。下面这个例子演示了如何创建基于 effect 的消息。

示例(基于 Effect 的消息,配合国际化服务)

import { Context, Effect, Either, Option, Schema, ParseResult } from "effect"

// Define an internationalization service for custom messages
class Messages extends Context.Tag("Messages")<
  Messages,
  {
    NonEmpty: string
  }
>() {}

// Define a schema with an effect-based message
// that depends on the Messages service
const Name = Schema.NonEmptyString.annotations({
  message: () =>
    Effect.gen(function* () {
      // Attempt to retrieve the Messages service
      const service = yield* Effect.serviceOption(Messages)
      // Use a fallback message if the service is not available
      return Option.match(service, {
        onNone: () => "Invalid string",
        onSome: (messages) => messages.NonEmpty,
      })
    }),
})

// Attempt to decode an empty string without providing the Messages service
Schema.decodeUnknownEither(Name)("").pipe(
  Either.mapLeft((error) =>
    ParseResult.TreeFormatter.formatError(error).pipe(
      Effect.runSync,
      console.log,
    ),
  ),
)
// Output: Invalid string

// Provide the Messages service to customize the error message
Schema.decodeUnknownEither(Name)("").pipe(
  Either.mapLeft((error) =>
    ParseResult.TreeFormatter.formatError(error).pipe(
      Effect.provideService(Messages, {
        NonEmpty: "should be non empty",
      }),
      Effect.runSync,
      console.log,
    ),
  ),
)
// Output: should be non empty

缺失字段的消息

借助 missingMessage 注解,你可以为缺失的字段或元组元素提供自定义消息。

示例(缺失属性的自定义消息)

下面这个例子为 Person schema 中缺失的 name 属性定义了自定义消息。

import { Schema } from "effect"

const Person = Schema.Struct({
  name: Schema.propertySignature(Schema.String).annotations({
    // Custom message if "name" is missing
    missingMessage: () => "Name is required",
  }),
})

Schema.decodeUnknownSync(Person)({})
/*
throws:
ParseError: { readonly name: string }
└─ ["name"]
   └─ Name is required
*/

示例(缺失元组元素的自定义消息)

这里,Point 元组 schema 中的每个元素在缺失时都有各自的自定义消息。

import { Schema } from "effect"

const Point = Schema.Tuple(
  Schema.element(Schema.Number).annotations({
    // Message if X is missing
    missingMessage: () => "X coordinate is required",
  }),
  Schema.element(Schema.Number).annotations({
    // Message if Y is missing
    missingMessage: () => "Y coordinate is required",
  }),
)

Schema.decodeUnknownSync(Point)([], { errors: "all" })
/*
throws:
ParseError: readonly [number, number]
├─ [0]
│  └─ X coordinate is required
└─ [1]
   └─ Y coordinate is required
*/