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

Class API

学习使用类来定义和扩展 schema,涵盖校验、自定义逻辑,以及相等性检查与变换等高级特性。

在处理 schema 时,除了 Schema.Struct 构造函数之外,你还有另一种选择。 你可以通过 Schema.Class 工具来利用类的能力,它自带一套针对常见用例量身定制的优势:

类提供了若干能够简化 schema 创建过程的特性:

  • 一体化定义:借助类,你可以同时定义一个 schema 和一个不透明类型(opaque type)。
  • 共享功能:你可以通过类的方法或 getter 加入共享功能。
  • 值的哈希与相等性:利用内置能力来检查值相等性并应用哈希(这得益于 Class 实现了 Data.Class)。
Class Schemas Are Transformations

使用 Schema.Class 定义的类充当的是变换(transformation)。 详见类 Schema 是变换

定义

要用 Schema.Class 定义一个类,你需要指定:

  • 所创建类的类型
  • 该类的唯一标识符
  • 你想要的字段

示例(定义一个 Schema 类)

import { Schema } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {}

在这个示例中,Person 既是一个 schema,也是一个 TypeScript 类。Person 的实例通过所定义的 schema 创建,从而确保它们符合指定的字段。

示例(创建实例)

import { Schema } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {}

console.log(new Person({ id: 1, name: "John" }))
/*
Output:
Person { id: 1, name: 'John' }
*/

// Using the factory function
console.log(Person.make({ id: 1, name: "John" }))
/*
Output:
Person { id: 1, name: 'John' }
*/
Why Use Identifiers?

你需要指定一个标识符,让该类成为全局的。这能确保具有相同标识符的两个类指向同一个实例,从而避免依赖 instanceof 检查。

这一行为与我们处理其他基于类的 API(例如 Context.Tag)的方式类似。

在可能发生实时重载(live reload)的场景中,使用唯一标识符尤其有用,因为它有助于在重载之间保留实例。它确保了实例不会被重复创建(尽管这不应该发生,但某些打包工具和框架的行为可能难以预料)。

类 Schema 是变换

类 schema 会把一个 struct schema 变换 成一个代表类类型的声明(declaration) schema。

  • 解码时,普通对象会被转换成该类的一个实例。
  • 编码时,类实例会被转换回普通对象。

示例(解码与编码一个类)

import { Schema } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {}

const person = Person.make({ id: 1, name: "John" })

// Decode from a plain object into a class instance
const decoded = Schema.decodeUnknownSync(Person)({ id: 1, name: "John" })
console.log(decoded)
// Output: Person { id: 1, name: 'John' }

// Encode a class instance back into a plain object
const encoded = Schema.encodeUnknownSync(Person)(person)
console.log(encoded)
// Output: { id: 1, name: 'John' }

定义不含字段的类

当你的 schema 不需要任何字段时,可以定义一个使用空对象的类。

示例(定义并使用一个不带参数的类)

import { Schema } from "effect"

// Define a class with no fields
class NoArgs extends Schema.Class<NoArgs>("NoArgs")({}) {}

// Create an instance using the default constructor
const noargs1 = new NoArgs()

// Alternatively, create an instance by explicitly passing an empty object
const noargs2 = new NoArgs({})

定义带过滤器的类

过滤器让你能够在解码、编码或创建实例时校验输入。你可以传入一个应用了过滤器的 Schema.Struct,而不是直接指定原始字段。

示例(为 Schema 类应用过滤器)

import { Schema } from "effect"

class WithFilter extends Schema.Class<WithFilter>("WithFilter")(
  Schema.Struct({
    a: Schema.NumberFromString,
    b: Schema.NumberFromString,
  }).pipe(Schema.filter(({ a, b }) => a >= b || "a must be greater than b")),
) {}

// Constructor
console.log(new WithFilter({ a: 1, b: 2 }))
/*
throws:
ParseError: WithFilter (Constructor)
└─ Predicate refinement failure
   └─ a must be greater than b
*/

// Decoding
console.log(Schema.decodeUnknownSync(WithFilter)({ a: "1", b: "2" }))
/*
throws:
ParseError: (WithFilter (Encoded side) <-> WithFilter)
└─ Encoded side transformation failure
   └─ WithFilter (Encoded side)
      └─ Predicate refinement failure
         └─ a must be greater than b
*/

通过类构造函数验证属性

当你使用 Schema.Class 定义一个类时,构造函数会自动检查所提供的属性是否符合 schema 的规则。

定义并实例化一个有效的类实例

构造函数确保每个属性(例如 idname)都符合 schema。例如,id 必须是数字,name 必须是非空字符串。

示例(创建一个有效实例)

import { Schema } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {}

// Create an instance with valid properties
const john = new Person({ id: 1, name: "John" })

处理无效属性

如果在实例化时提供了无效的属性,构造函数会抛出错误,并说明验证失败的原因。

示例(用无效属性创建一个实例)

import { Schema } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {}

// Attempt to create an instance with an invalid `name`
new Person({ id: 1, name: "" })
/*
throws:
ParseError: Person (Constructor)
└─ ["name"]
   └─ NonEmptyString
      └─ Predicate refinement failure
         └─ Expected NonEmptyString, actual ""
*/

该错误清晰地指出,name 字段未能满足 NonEmptyString 的要求。

绕过验证

在某些场景下,你可能希望绕过验证逻辑。虽然通常不建议这样做,但库提供了一个选项来实现它。

示例(绕过验证)

import { Schema } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {}

// Bypass validation during instantiation
const john = new Person({ id: 1, name: "" }, true)

// Or use the `disableValidation` option explicitly
new Person({ id: 1, name: "" }, { disableValidation: true })

类中的自动哈希与相等性

使用 Schema.Class 创建的类的实例通过集成 Data.Class 来支持 Equal trait。这让按值比较变得简单直接,即使是在不同的实例之间。

基本的相等性检查

如果两个类实例的属性值完全相同,它们就被认为是相等的。

示例(比较属性相等的实例)

import { Schema } from "effect"
import { Equal } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {}

const john1 = new Person({ id: 1, name: "John" })
const john2 = new Person({ id: 1, name: "John" })

// Compare instances
console.log(Equal.equals(john1, john2))
// Output: true

嵌套或复杂属性

Equal trait 只在第一层进行比较。如果某个属性是更复杂的结构(例如数组),那么即使这些数组本身具有完全相同的值,实例也可能不会被认为是相等的。

示例(数组的浅层相等性)

import { Schema } from "effect"
import { Equal } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
  hobbies: Schema.Array(Schema.String), // Standard array schema
}) {}

const john1 = new Person({
  id: 1,
  name: "John",
  hobbies: ["reading", "coding"],
})
const john2 = new Person({
  id: 1,
  name: "John",
  hobbies: ["reading", "coding"],
})

// Equality fails because `hobbies` are not deeply compared
console.log(Equal.equals(john1, john2))
// Output: false

要让数组这类嵌套结构实现深层相等性,可以结合 Data.array 使用 Schema.Data。这样库就会比较数组的每个元素,而不是把整个数组当作一个整体。

示例(使用 Schema.Data 实现深层相等性)

import { Schema } from "effect"
import { Data, Equal } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
  hobbies: Schema.Data(Schema.Array(Schema.String)), // Enable deep equality
}) {}

const john1 = new Person({
  id: 1,
  name: "John",
  hobbies: Data.array(["reading", "coding"]),
})
const john2 = new Person({
  id: 1,
  name: "John",
  hobbies: Data.array(["reading", "coding"]),
})

// Equality succeeds because `hobbies` are deeply compared
console.log(Equal.equals(john1, john2))
// Output: true

用自定义逻辑扩展类

Schema 类提供了灵活性,允许你加入自定义的 getter 和方法,从而把功能扩展到已定义的字段之外。

添加自定义 getter

getter 可以用来从类的字段中派生出计算值。例如,Person 类可以包含一个 getter,用于返回大写形式的 name 属性。

示例(添加一个返回大写名字的 getter)

import { Schema } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {
  // Custom getter to return the name in uppercase
  get upperName() {
    return this.name.toUpperCase()
  }
}

const john = new Person({ id: 1, name: "John" })

// Use the custom getter
console.log(john.upperName)
// Output: "JOHN"

添加自定义方法

除了 getter,你还可以定义方法来封装更复杂的逻辑或涉及类字段的操作。

示例(添加一个方法)

import { Schema } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {
  // Custom method to return a greeting
  greet() {
    return `Hello, my name is ${this.name}.`
  }
}

const john = new Person({ id: 1, name: "John" })

// Use the custom method
console.log(john.greet())
// Output: "Hello, my name is John."

将类用作 Schema 定义

当你用 Schema.Class 定义一个类时,它既充当 schema,也充当类。这种双重功能让该类可以在任何需要 schema 的地方使用。

示例(在数组 schema 中使用一个类)

import { Schema } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {}

// Use the Person class in an array schema
const Persons = Schema.Array(Person)

//     ┌─── readonly Person[]
//     ▼
type Type = typeof Persons.Type

暴露的值

该类还包含一个 fields 静态属性,它列出了在创建类时所定义的字段。

示例(访问 fields 属性)

import { Schema } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {}

//       ┌─── {
//       |      readonly id: typeof Schema.Number;
//       |      readonly name: typeof Schema.NonEmptyString;
//       |    }
//       ▼
Person.fields

添加注解

Schema.Class 定义一个类,类似于创建一个变换 schema,它会把一个 struct schema 转换成一个代表该类类型的声明(declaration) schema。

例如,考虑下面这个类定义:

import { Schema } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {}

在底层,这个定义会创建一个变换 schema,它把:

Schema.Struct({
  id: Schema.Number,
  name: Schema.NonEmptyString,
})

映射为代表 Person 类的 schema:

Schema.declare((input) => input instanceof Person)

因此,用 Schema.Class 定义一个 schema 会涉及三个 schema:

  • “from” schema(即 struct)
  • “to” schema(即类)
  • “transformation” schema(struct -> class)

你可以通过向 Schema.Class API 传入一个元组作为第二个参数,来分别为这三个 schema 添加注解。

示例(为类 schema 的不同部分添加注解)

import { Schema, SchemaAST } from "effect"

class Person extends Schema.Class<Person>("Person")(
  {
    id: Schema.Number,
    name: Schema.NonEmptyString,
  },
  [
    // Annotations for the "to" schema
    { description: `"to" description` },

    // Annotations for the "transformation schema
    { description: `"transformation" description` },

    // Annotations for the "from" schema
    { description: `"from" description` },
  ],
) {}

console.log(SchemaAST.getDescriptionAnnotation(Person.ast.to))
// Output: { _id: 'Option', _tag: 'Some', value: '"to" description' }

console.log(SchemaAST.getDescriptionAnnotation(Person.ast))
// Output: { _id: 'Option', _tag: 'Some', value: '"transformation" description' }

console.log(SchemaAST.getDescriptionAnnotation(Person.ast.from))
// Output: { _id: 'Option', _tag: 'Some', value: '"from" description' }

如果你不想为全部三个 schema 都添加注解,可以对想要跳过的那些传入 undefined

示例(跳过部分注解)

import { Schema, SchemaAST } from "effect"

class Person extends Schema.Class<Person>("Person")(
  {
    id: Schema.Number,
    name: Schema.NonEmptyString,
  },
  [
    // No annotations for the "to" schema
    undefined,

    // Annotations for the "transformation schema
    { description: `"transformation" description` },
  ],
) {}

console.log(SchemaAST.getDescriptionAnnotation(Person.ast.to))
// Output: { _id: 'Option', _tag: 'None' }

console.log(SchemaAST.getDescriptionAnnotation(Person.ast))
// Output: { _id: 'Option', _tag: 'Some', value: '"transformation" description' }

console.log(SchemaAST.getDescriptionAnnotation(Person.ast.from))
// Output: { _id: 'Option', _tag: 'None' }

默认情况下,用于定义该类的唯一标识符也会被应用为 Class Schema 的默认 identifier 注解。

示例(默认的标识符注解)

import { Schema, SchemaAST } from "effect"

// Used as default identifier annotation ────┐
//                                           |
//                                           ▼
class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {}

console.log(SchemaAST.getIdentifierAnnotation(Person.ast.to))
// Output: { _id: 'Option', _tag: 'Some', value: 'Person' }

递归 schema

当你需要定义一个依赖自身的 schema 时,比如在处理递归数据结构时,Schema.suspend 组合子就会很有用。 在这个例子中,Category schema 依赖自身,因为它有一个 subcategories 字段,该字段是 Category 对象的数组。

示例(自引用 schema)

import { Schema } from "effect"

// Define a Category schema with a recursive subcategories field
class Category extends Schema.Class<Category>("Category")({
  name: Schema.String,
  subcategories: Schema.Array(
    Schema.suspend((): Schema.Schema<Category> => Category),
  ),
}) {}
Correct Inference

必须添加显式的类型注解,否则 TypeScript 将难以正确推断类型。 没有这个注解时,你可能会遇到下面这条错误消息:

示例(缺少类型注解的报错)

import { Schema } from "effect"

// @errors: 2506 7024
class Category extends Schema.Class<Category>("Category")({
  name: Schema.String,
  subcategories: Schema.Array(Schema.suspend(() => Category)),
}) {}

互递归 schema

有时,schema 之间会以互递归的方式相互依赖。例如,一个算术表达式树可能包含 Expression 节点,这些节点要么是数字,要么是 Operation 节点,而 Operation 节点又会反过来引用 Expression 节点。

示例(算术表达式树)

import { Schema } from "effect"

class Expression extends Schema.Class<Expression>("Expression")({
  type: Schema.Literal("expression"),
  value: Schema.Union(
    Schema.Number,
    Schema.suspend((): Schema.Schema<Operation> => Operation),
  ),
}) {}

class Operation extends Schema.Class<Operation>("Operation")({
  type: Schema.Literal("operation"),
  operator: Schema.Literal("+", "-"),
  left: Expression,
  right: Expression,
}) {}

Encoded 与 Type 不同的递归类型

定义 Encoded 类型与 Type 类型不一致的递归 schema 会引入额外的复杂性。例如,如果 schema 中包含会转换数据的字段(比如 NumberFromString),EncodedType 类型就可能对不上。

在这种情况下,我们需要为 Encoded 类型定义一个接口。

我们来看一个例子:假设我们想给 Category schema 添加一个 id 字段,其中 id 的 schema 是 NumberFromString。 需要注意,NumberFromString 是一个把字符串转换成数字的 schema,因此 NumberFromStringTypeEncoded 类型并不相同,分别是 numberstring。 当我们把这个字段添加到 Category schema 时,TypeScript 会报错:

import { Schema } from "effect"

class Category extends Schema.Class<Category>("Category")({
  id: Schema.NumberFromString,
  name: Schema.String,
  subcategories: Schema.Array(
    // @errors: 2322
    Schema.suspend((): Schema.Schema<Category> => Category),
  ),
}) {}

这个错误之所以出现,是因为显式注解 S.suspend((): S.Schema<Category> => Category 已经不够用了,需要通过显式添加 Encoded 类型来调整:

示例(用显式的 Encoded 类型调整 schema)

import { Schema } from "effect"

interface CategoryEncoded {
  readonly id: string
  readonly name: string
  readonly subcategories: ReadonlyArray<CategoryEncoded>
}

class Category extends Schema.Class<Category>("Category")({
  id: Schema.NumberFromString,
  name: Schema.String,
  subcategories: Schema.Array(
    Schema.suspend((): Schema.Schema<Category, CategoryEncoded> => Category),
  ),
}) {}

正如我们所见,为了让递归 schema 的定义成为可能,必须为 schema 的 Encoded 定义一个接口,这会让事情变得复杂,而且相当繁琐。 缓解这一问题的一种模式是,把负责递归的字段与其它所有字段分离开来。

示例(分离递归字段)

import { Schema } from "effect"

const fields = {
  id: Schema.NumberFromString,
  name: Schema.String,
  // ...possibly other fields
}

interface CategoryEncoded extends Schema.Struct.Encoded<typeof fields> {
  // Define `subcategories` using recursion
  readonly subcategories: ReadonlyArray<CategoryEncoded>
}

class Category extends Schema.Class<Category>("Category")({
  ...fields, // Include the fields
  subcategories: Schema.Array(
    // Define `subcategories` using recursion
    Schema.suspend((): Schema.Schema<Category, CategoryEncoded> => Category),
  ),
}) {}

Tagged Class 变体

你也可以创建继承自 effect/Data 模块中 TaggedClassTaggedError 的类。

示例(创建 Tagged Class 与 Tagged Error)

import { Schema } from "effect"

// Define a tagged class with a "name" field
class TaggedPerson extends Schema.TaggedClass<TaggedPerson>()("TaggedPerson", {
  name: Schema.String,
}) {}

// Define a tagged error with a "status" field
class HttpError extends Schema.TaggedError<HttpError>()("HttpError", {
  status: Schema.Number,
}) {}

const joe = new TaggedPerson({ name: "Joe" })
console.log(joe._tag)
// Output: "TaggedPerson"

const error = new HttpError({ status: 404 })
console.log(error._tag)
// Output: "HttpError"

console.log(error.stack) // access the stack trace

扩展现有类

extend 静态工具允许你通过添加额外的字段和功能来增强已有的 schema 类。这种方式有助于在现有 schema 的基础上继续构建,而不必从头重新定义它们。

示例(扩展一个 schema 类)

import { Schema } from "effect"

// Define the base class
class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {
  // A custom getter that converts the name to uppercase
  get upperName() {
    return this.name.toUpperCase()
  }
}

// Extend the base class to include an "age" field
class PersonWithAge extends Person.extend<PersonWithAge>("PersonWithAge")({
  age: Schema.Number,
}) {
  // A custom getter to check if the person is an adult
  get isAdult() {
    return this.age >= 18
  }
}

// Usage
const john = new PersonWithAge({ id: 1, name: "John", age: 25 })
console.log(john.upperName) // Output: "JOHN"
console.log(john.isAdult) // Output: true

注意,扩展类时只能添加额外的字段。

示例(尝试覆盖已有字段)

import { Schema } from "effect"

class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.NonEmptyString,
}) {
  get upperName() {
    return this.name.toUpperCase()
  }
}

class BadExtension extends Person.extend<BadExtension>("BadExtension")({
  name: Schema.Number,
}) {}
/*
throws:
Error: Duplicate property signature
details: Duplicate key "name"
*/

这个错误之所以出现,是因为允许字段被覆盖并不安全。它可能会干扰类上任何依赖原始定义的 getter 或方法。例如在这个例子中,如果 name 字段被改成数字,upperName getter 就会失效。

变换

你可以为 schema 类添加带 effect 的变换,从而丰富或校验实体,尤其是在处理来自数据库或 API 等外部系统的数据时。

示例(带 effect 的变换)

下面的示例演示了如何给 Person 类添加一个 age 字段。age 的值会根据 id 字段异步推导出来。

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

// Base class definition
class Person extends Schema.Class<Person>("Person")({
  id: Schema.Number,
  name: Schema.String,
}) {}

console.log(Schema.decodeUnknownSync(Person)({ id: 1, name: "name" }))
/*
Output:
Person { id: 1, name: 'name' }
*/

// Simulate fetching age asynchronously based on id
function getAge(id: number): Effect.Effect<number, Error> {
  return Effect.succeed(id + 2)
}

// Extended class with a transformation
class PersonWithTransform extends Person.transformOrFail<PersonWithTransform>(
  "PersonWithTransform",
)(
  {
    age: Schema.optionalWith(Schema.Number, { exact: true, as: "Option" }),
  },
  {
    // Decoding logic for the new field
    decode: (input) =>
      Effect.mapBoth(getAge(input.id), {
        onFailure: (e) =>
          new ParseResult.Type(Schema.String.ast, input.id, e.message),
        // Must return { age: Option<number> }
        onSuccess: (age) => ({ ...input, age: Option.some(age) }),
      }),
    encode: ParseResult.succeed,
  },
) {}

Schema.decodeUnknownPromise(PersonWithTransform)({
  id: 1,
  name: "name",
}).then(console.log)
/*
Output:
PersonWithTransform {
  id: 1,
  name: 'name',
  age: { _id: 'Option', _tag: 'Some', value: 3 }
}
*/

// Extended class with a conditional Transformation
class PersonWithTransformFrom extends Person.transformOrFailFrom<PersonWithTransformFrom>(
  "PersonWithTransformFrom",
)(
  {
    age: Schema.optionalWith(Schema.Number, { exact: true, as: "Option" }),
  },
  {
    decode: (input) =>
      Effect.mapBoth(getAge(input.id), {
        onFailure: (e) =>
          new ParseResult.Type(Schema.String.ast, input, e.message),
        // Must return { age?: number }
        onSuccess: (age) => (age > 18 ? { ...input, age } : { ...input }),
      }),
    encode: ParseResult.succeed,
  },
) {}

Schema.decodeUnknownPromise(PersonWithTransformFrom)({
  id: 1,
  name: "name",
}).then(console.log)
/*
Output:
PersonWithTransformFrom {
  id: 1,
  name: 'name',
  age: { _id: 'Option', _tag: 'None' }
}
*/

究竟该使用哪个 API —— transformOrFail 还是 transformOrFailFrom —— 取决于你希望何时执行变换:

  1. 使用 transformOrFail

    • 变换发生在整个流程的末尾。
    • 它期望你提供一个类型为 { age: Option<number> } 的值。
    • 处理完初始输入后,新的变换才会生效,你需要确保最终输出符合指定的结构。
  2. 使用 transformOrFailFrom

    • 新的变换会在初始输入被处理时立即开始。
    • 你应当提供一个 { age?: number } 值。
    • 基于这个新的输入,后续的变换 Schema.optionalWith(Schema.Number, { exact: true, as: "Option" }) 会被执行。
    • 这种方式允许立即处理输入,并可能影响后续的变换。