Class API
学习使用类来定义和扩展 schema,涵盖校验、自定义逻辑,以及相等性检查与变换等高级特性。
在处理 schema 时,除了 Schema.Struct 构造函数之外,你还有另一种选择。
你可以通过 Schema.Class 工具来利用类的能力,它自带一套针对常见用例量身定制的优势:
类提供了若干能够简化 schema 创建过程的特性:
- 一体化定义:借助类,你可以同时定义一个 schema 和一个不透明类型(opaque type)。
- 共享功能:你可以通过类的方法或 getter 加入共享功能。
- 值的哈希与相等性:利用内置能力来检查值相等性并应用哈希(这得益于
Class实现了 Data.Class)。
使用 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' }
*/
你需要指定一个标识符,让该类成为全局的。这能确保具有相同标识符的两个类指向同一个实例,从而避免依赖 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 的规则。
定义并实例化一个有效的类实例
构造函数确保每个属性(例如 id 和 name)都符合 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),
),
}) {}
必须添加显式的类型注解,否则 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),Encoded 与 Type 类型就可能对不上。
在这种情况下,我们需要为 Encoded 类型定义一个接口。
我们来看一个例子:假设我们想给 Category schema 添加一个 id 字段,其中 id 的 schema 是 NumberFromString。
需要注意,NumberFromString 是一个把字符串转换成数字的 schema,因此 NumberFromString 的 Type 和 Encoded 类型并不相同,分别是 number 和 string。
当我们把这个字段添加到 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 模块中 TaggedClass 和 TaggedError 的类。
示例(创建 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 —— 取决于你希望何时执行变换:
-
使用
transformOrFail:- 变换发生在整个流程的末尾。
- 它期望你提供一个类型为
{ age: Option<number> }的值。 - 处理完初始输入后,新的变换才会生效,你需要确保最终输出符合指定的结构。
-
使用
transformOrFailFrom:- 新的变换会在初始输入被处理时立即开始。
- 你应当提供一个
{ age?: number }值。 - 基于这个新的输入,后续的变换
Schema.optionalWith(Schema.Number, { exact: true, as: "Option" })会被执行。 - 这种方式允许立即处理输入,并可能影响后续的变换。