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

高级用法

学习定义和扩展数据 schema 的高级技巧,包括递归类型与互递归类型、可选字段、品牌类型以及 schema 变换。

声明新的数据类型

原始数据类型

要为一种不透明的、非泛型的数据类型声明 schema,可以把 Schema.declare 与类型守卫配合使用。下面的示例以 File 展示了这种底层模式。

说明

Effect 已经提供了 Schema.File;下面这里的声明仅用于说明。

示例(为 File 声明 Schema)

import { Schema } from "effect"

// Declare a schema for the File type using a type guard
const FileSchema = Schema.declare(
  (input: unknown): input is File => input instanceof File,
)

const decode = Schema.decodeUnknownSync(FileSchema)

// Decoding a valid File object
console.log(decode(new File([], "")))
/*
Output:
File { size: 0, type: '', name: '', lastModified: 1724774163056 }
*/

// Decoding an invalid input
decode(null)
/*
throws
SchemaError: Expected <Declaration>
*/
Adding Annotations

像 identifier、description 这样的注解有助于改进报错信息, 也让 schema 具备自解释性。

你可以添加 identifier、title 和 description 注解,让这个声明更容易被人和 schema 解释器理解。identifier 和 title 还能改进默认的期望值消息。

  • Identifier:schema 的唯一名称
  • Title:简短、描述性的标题
  • Description:对 schema 用途的详细说明

示例(声明带注解的 Schema)

import { Schema } from "effect"

// Declare a schema for the File type with additional annotations
const FileSchema = Schema.declare(
  (input: unknown): input is File => input instanceof File,
  {
    // A unique identifier for the schema
    identifier: "File",
    // Detailed description of the schema
    description: "The `File` type in JavaScript",
  },
)

const decode = Schema.decodeUnknownSync(FileSchema)

// Decoding a valid File object
console.log(decode(new File([], "")))
/*
Output:
File { size: 0, type: '', name: '', lastModified: 1724774163056 }
*/

// Decoding an invalid input
decode(null)
/*
throws
SchemaError: Expected File
*/

类型构造器

类型构造器是接收一个或多个类型作为参数、并返回一个新类型的泛型类型。要为类型构造器定义 schema,可以使用 Schema.declare 函数。

示例(为 ReadonlySet<T> 声明 Schema)

import {
  Effect,
  Schema,
  SchemaIssue,
  SchemaParser,
  SchemaTransformation,
} from "effect"

export const MyReadonlySet = <S extends Schema.Constraint>(
  // Schema for the elements of the Set
  item: S,
) =>
  Schema.declareConstructor<
    ReadonlySet<S["Type"]>,
    ReadonlySet<S["Encoded"]>
  >()(
    // Store the schema for the Set's elements
    [item],
    // Decoding function
    ([item]) =>
      (input, ast, options) => {
        if (input instanceof Set) {
          // Decode each element in the Set
          return Effect.map(
            SchemaParser.decodeUnknownEffect(Schema.Array(item))(
              Array.from(input.values()),
              options,
            ),
            // Return a ReadonlySet containing the decoded elements
            (values): ReadonlySet<S["Type"]> => new Set(values),
          )
        }
        // Handle invalid input
        return Effect.fail(new SchemaIssue.InvalidType(ast))
      },
    {
      expected: "ReadonlySet",
      // Define the encoding side by linking back to an Array schema
      toCodec: ([item]) =>
        Schema.link<ReadonlySet<S["Encoded"]>>()(
          Schema.Array(item),
          SchemaTransformation.transform({
            // Decode an array into a ReadonlySet
            decode: (values): ReadonlySet<S["Encoded"]> => new Set(values),
            // Encode a ReadonlySet back into an array
            encode: (set) => Array.from(set.values()),
          }),
        ),
    },
  )

// Define a schema for a ReadonlySet of numbers
const setOfNumbers = MyReadonlySet(Schema.FiniteFromString)

const decode = Schema.decodeUnknownSync(setOfNumbers)

console.log(decode(new Set(["1", "2", "3"]))) // Set(3) { 1, 2, 3 }

// Decode an invalid input
decode(null)
/*
throws
SchemaError: Expected ReadonlySet
*/

// Decode a Set with an invalid element
decode(new Set(["1", null, "3"]))
/*
throws
SchemaError: Expected string
  at [1]
*/
Decoding/Encoding Limitations

declareConstructor 返回的解析器本身是 effectful 的,但最终生成的 codec 只会依赖其类型参数 schema 已经需要的解码与编码服务。

添加解释器注解

定义一种新的数据类型时,诸如 Arbitrary 或 Formatter 这样的 schema 解释器可能不知道如何处理这个新类型。 这会导致错误,因为解释器可能缺少生成实例或产出可读输出所需的信息:

示例(在没有必需注解的情况下尝试生成 Arbitrary 值)

import { Schema } from "effect"

// Define a schema for the File type
const FileSchema = Schema.declare(
  (input: unknown): input is File => input instanceof File,
  {
    identifier: "File",
  },
)

// Try creating an Arbitrary instance for the schema
const arb = Schema.toArbitrary(FileSchema)
/*
throws:
Error: Missing annotation
details: Generating an Arbitrary for this schema requires an "arbitrary" annotation
schema (Declaration): File
*/

在上面的示例中,为 FileSchema 生成 arbitrary 值会失败,因为解释器缺少必需的注解。要解决这个问题,请提供用于生成 arbitrary 数据的注解:

示例(为自定义的 File Schema 添加 Arbitrary 注解)

import { Schema } from "effect"
import { FastCheck } from "effect/testing"

const FileSchema = Schema.declare(
  (input: unknown): input is File => input instanceof File,
  {
    identifier: "File",
    // Provide a function to generate random File instances
    toArbitrary: () => (fc) =>
      fc
        .tuple(fc.string(), fc.string())
        .map(([content, path]) => new File([content], path)),
  },
)

// Create an Arbitrary instance for the schema
const arb = Schema.toArbitrary(FileSchema)

// Generate sample files using the Arbitrary instance
const files = FastCheck.sample(arb, 2)
console.log(files)
/*
Example Output:
[
  File { size: 5, type: '', name: 'C', lastModified: 1706435571176 },
  File { size: 1, type: '', name: '98Ggmc', lastModified: 1706435571176 }
]
*/

关于如何为 Arbitrary 解释器添加注解的更多细节,请参阅 Arbitrary 文档。

品牌类型

TypeScript 的类型系统是结构化的,这意味着任何两个在结构上等价的类型都会被视为同一个类型。 当语义上不同的类型被当作同一个类型处理时,这就会带来问题。

示例(结构化类型带来的问题)

type UserId = string
type Username = string

declare const getUser: (id: UserId) => object

const myUsername: Username = "gcanti"

getUser(myUsername) // This erroneously works

在上面的示例中,UserId 和 Username 都是同一个类型 string 的别名。这意味着 getUser 函数会误把一个 Username 当作合法的 UserId 接受,从而带来 bug 和错误。

为了避免这种情况,Effect 引入了品牌类型(branded types)。这类类型会给一个类型附加一个唯一标识(也就是 “brand”),让你能够区分结构相似但语义不同的类型。

示例(定义品牌类型)

import { Brand } from "effect"

type UserId = string & Brand.Brand<"UserId">
type Username = string

declare const getUser: (id: UserId) => object

const myUsername: Username = "gcanti"

// @errors: 2345
getUser(myUsername)

通过把 UserId 定义为品牌类型,getUser 函数就只能接受 UserId 类型的值,而不能接受普通字符串或其他与字符串兼容的类型。这有助于避免因误把错误类型的值传给函数而引发的 bug。

为品牌类型定义 schema 有两种方式,取决于你是:

  • 想从零开始定义 schema
  • 已经通过 effect/Brand 定义了品牌类型,想复用它来定义 schema

从零定义品牌 schema

要从零为品牌类型定义 schema,请使用 Schema.brand 函数。

示例(为品牌类型创建 schema)

import { Schema } from "effect"

const UserId = Schema.String.pipe(Schema.brand("UserId"))

// string & Brand<"UserId">
type UserId = typeof UserId.Type

复用已有的品牌构造器

如果你已经使用 effect/Brand 模块定义过品牌类型,就可以通过 Schema.fromBrand 函数复用它来定义 schema。

示例(复用已有的品牌类型)

import { Schema } from "effect"
import { Brand } from "effect"

// the existing branded type
type UserId = string & Brand.Brand<"UserId">

const UserId = Brand.nominal<UserId>()

// Define a schema for the branded type
const UserIdSchema = Schema.String.pipe(Schema.fromBrand("UserId", UserId))

使用默认构造器

Schema.brand 函数包含一个默认构造器,便于创建品牌类型的值。

import { Schema } from "effect"

const UserId = Schema.String.pipe(Schema.brand("UserId"))

const userId = UserId.make("123") // => "123"

属性签名

属性签名组合子可以分别在编码侧和解码侧独立控制 Struct 的字段。它们可以让某个键变成可选、允许 undefined、提供默认值、附加键级别的注解,或者重命名编码后的键。

基本用法

属性签名可以带注解定义,从而为字段提供额外的上下文。

示例(为属性签名添加注解)

import { Schema } from "effect"

const Person = Schema.Struct({
  name: Schema.String,
  age: Schema.FiniteFromString.pipe(
    Schema.annotateKey({
      title: "Age", // Annotation to label the age field
    }),
  ),
})

字段元数据请使用 Schema.annotateKey。当外部表示使用不同的键时,请在 Struct 上使用 Schema.encodeKeys。

示例(从不同的键映射)

import { Schema } from "effect"

const Person = Schema.Struct({
  name: Schema.String,
  age: Schema.FiniteFromString, // Maps from "AGE" to "age"
}).pipe(Schema.encodeKeys({ age: "AGE" }))

console.log(Schema.decodeUnknownSync(Person)({ name: "name", AGE: "18" }))
// Output: { name: 'name', age: 18 }

可选字段

基本的可选属性

Schema.optional 让某个键变成可选,并在该键存在时允许 undefined。

解码
InputOutput
<missing value>remains <missing value>
undefinedremains undefined
e: Etransforms to t: T
编码
InputOutput
<missing value>remains <missing value>
undefinedremains undefined
t: Ttransforms back to e: E

示例(定义可选数字字段)

import { Schema } from "effect"

const Product = Schema.Struct({
  quantity: Schema.optional(Schema.FiniteFromString),
})

//     ┌─── { readonly quantity?: string | undefined; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity?: number | undefined; }
//     ▼
type Type = typeof Product.Type

// Decoding examples

console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" }))
// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({}))
// Output: {}
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))
// Output: { quantity: undefined }

// Encoding examples

console.log(Schema.encodeSync(Product)({ quantity: 1 }))
// Output: { quantity: "1" }
console.log(Schema.encodeSync(Product)({}))
// Output: {}
console.log(Schema.encodeSync(Product)({ quantity: undefined }))
// Output: { quantity: undefined }

可空的可选字段

当 null 应当被视为缺失值时,可以组合使用 Schema.optional、Schema.NullOr 以及可空字段变换。

解码
InputOutput
<missing value>remains <missing value>
undefinedremains undefined
nulltransforms to <missing value>
e: Etransforms to t: T
编码
InputOutput
<missing value>remains <missing value>
undefinedremains undefined
t: Ttransforms back to e: E

示例(把 Null 作为缺失值处理)

import { Option, Predicate, Schema, SchemaGetter } from "effect"

const Product = Schema.Struct({
  quantity: Schema.optional(Schema.NullOr(Schema.FiniteFromString)).pipe(
    Schema.decodeTo(Schema.optional(Schema.Finite), {
      decode: SchemaGetter.transformOptional((o) =>
        o.pipe(Option.filter(Predicate.isNotNull)),
      ),
      encode: SchemaGetter.transformOptional((o) => o),
    }),
  ),
})

//     ┌─── { readonly quantity?: string | null | undefined; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity?: number | undefined; }
//     ▼
type Type = typeof Product.Type

// Decoding examples

console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" }))
// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({}))
// Output: {}
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))
// Output: { quantity: undefined }
console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))
// Output: {}

// Encoding examples

console.log(Schema.encodeSync(Product)({ quantity: 1 }))
// Output: { quantity: "1" }
console.log(Schema.encodeSync(Product)({}))
// Output: {}
console.log(Schema.encodeSync(Product)({ quantity: undefined }))
// Output: { quantity: undefined }

精确的可选键

Schema.optionalKey 让某个键变成可选,但不会在该键的值类型中加入 undefined。如果该键存在,它的值必须能被所包裹的 schema 接受。

解码
InputOutput
<missing value>remains <missing value>
undefinedSchemaError
e: Etransforms to t: T
编码
InputOutput
<missing value>remains <missing value>
t: Ttransforms back to e: E

示例(对可选字段使用精确性)

import { Schema } from "effect"

const Product = Schema.Struct({
  quantity: Schema.optionalKey(Schema.FiniteFromString),
})

//     ┌─── { readonly quantity?: string; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity?: number; }
//     ▼
type Type = typeof Product.Type

// Decoding examples

console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" }))
// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({}))
// Output: {}
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))
/*
throws:
SchemaError: Expected string
  at ["quantity"]
*/

// Encoding examples

console.log(Schema.encodeSync(Product)({ quantity: 1 }))
// Output: { quantity: "1" }
console.log(Schema.encodeSync(Product)({}))
// Output: {}

带可空性的精确可选键

当 null 应当被视为缺失的键、而 undefined 仍应被拒绝时,可以组合使用 Schema.optionalKey、Schema.NullOr 以及可空字段变换。

解码
InputOutput
<missing value>remains <missing value>
nulltransforms to <missing value>
undefinedSchemaError
e: Etransforms to t: T
编码
InputOutput
<missing value>remains <missing value>
t: Ttransforms back to e: E

示例(对可选字段使用精确性并把 Null 作为缺失值处理)

import { Option, Predicate, Schema, SchemaGetter } from "effect"

const Product = Schema.Struct({
  quantity: Schema.optionalKey(Schema.NullOr(Schema.FiniteFromString)).pipe(
    Schema.decodeTo(Schema.optionalKey(Schema.Finite), {
      decode: SchemaGetter.transformOptional((o) =>
        o.pipe(Option.filter(Predicate.isNotNull)),
      ),
      encode: SchemaGetter.transformOptional((o) => o),
    }),
  ),
})

//     ┌─── { readonly quantity?: string | null; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity?: number; }
//     ▼
type Type = typeof Product.Type

// Decoding examples

console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" }))
// Output: { quantity: 1 }
console.log(Schema.decodeUnknownSync(Product)({}))
// Output: {}
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))
/*
throws:
SchemaError: Expected string | null
  at ["quantity"]
*/
console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))
// Output: {}

// Encoding examples

console.log(Schema.encodeSync(Product)({ quantity: 1 }))
// Output: { quantity: "1" }
console.log(Schema.encodeSync(Product)({}))
// Output: {}

使用 never 类型表示可选字段

当你创建一个 schema 来复刻某个包含 never 类型可选字段的 TypeScript 类型时,例如:

type MyType = {
  readonly quantity?: never
}

这些字段的处理方式取决于 tsconfig.json 中的 exactOptionalPropertyTypes 设置。 该设置会影响 schema 应当把可选的 never 类型字段视为单纯不存在,还是允许把 undefined 作为它的值。

示例(exactOptionalPropertyTypes: false)

当该特性关闭时,你可以使用 Schema.optional 函数。这种方式允许该字段隐式接受 undefined 作为值。

import { Schema } from "effect"

const Product = Schema.Struct({
  quantity: Schema.optional(Schema.Never),
})

//     ┌─── { readonly quantity?: undefined; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity?: undefined; }
//     ▼
type Type = typeof Product.Type

示例(exactOptionalPropertyTypes: true)

当该特性开启时,请使用 Schema.optionalKey,这样该字段就只能缺失。

import { Schema } from "effect"

const Product = Schema.Struct({
  quantity: Schema.optionalKey(Schema.Never),
})

//     ┌─── { readonly quantity?: never; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity?: never; }
//     ▼
type Type = typeof Product.Type

默认值

使用 Schema.withDecodingDefaultType 或 Schema.withDecodingDefaultTypeKey 可以提供解码后的默认值。构造器默认值是独立的,可以用 Schema.withConstructorDefault 添加。

基本默认值

这是最简单的用例。如果输入缺失或为 undefined,就会应用默认值。

操作行为
解码如果输入缺失或为 undefined,则应用默认值
编码把输入 t: T 转换回 e: E

示例(当字段缺失或为 undefined 时应用默认值)

import { Effect, Schema } from "effect"

const Product = Schema.Struct({
  quantity: Schema.FiniteFromString.pipe(
    Schema.withDecodingDefaultType(Effect.succeed(1)), // Default value for quantity
    Schema.withConstructorDefault(Effect.succeed(1)),
  ),
})

//     ┌─── { readonly quantity?: string | undefined; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity: number; }
//     ▼
type Type = typeof Product.Type

// Decoding examples with default applied

console.log(Schema.decodeUnknownSync(Product)({}))
// Output: { quantity: 1 }

console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))
// Output: { quantity: 1 }

console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))
// Output: { quantity: 2 }

// Object construction examples with default applied

console.log(Product.make({}))
// Output: { quantity: 1 }

console.log(Product.make({ quantity: 2 }))
// Output: { quantity: 2 }

键缺失时的默认值

如果默认值只应在键缺失时应用、而不应在键存在但其值为 undefined 时应用,请使用 Schema.withDecodingDefaultTypeKey。

操作行为
解码仅当输入缺失时应用默认值
编码把输入 t: T 转换回 e: E

示例(仅在字段缺失时应用默认值)

import { Effect, Schema } from "effect"

const Product = Schema.Struct({
  quantity: Schema.FiniteFromString.pipe(
    Schema.withDecodingDefaultTypeKey(Effect.succeed(1)), // Default value for quantity, only if quantity is not provided
  ),
})

//     ┌─── { readonly quantity?: string; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity: number; }
//     ▼
type Type = typeof Product.Type

console.log(Schema.decodeUnknownSync(Product)({}))
// Output: { quantity: 1 }

console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))
// Output: { quantity: 2 }

console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))
/*
throws:
SchemaError: Expected string
  at ["quantity"]
*/

带可空性的默认值

当缺失、undefined 和 null 都应产生默认值时,可以把可选且可空的字段与 SchemaGetter.transformOptional 组合起来。

操作行为
解码如果输入缺失,或为 undefined 或 null,则应用默认值
编码把输入 t: T 转换回 e: E

示例(当字段缺失,或为 undefined 或 null 时应用默认值)

import { Option, Predicate, Schema, SchemaGetter } from "effect"

const Product = Schema.Struct({
  quantity: Schema.optional(Schema.NullOr(Schema.FiniteFromString)).pipe(
    Schema.decodeTo(Schema.Finite, {
      decode: SchemaGetter.transformOptional((o) =>
        o.pipe(
          Option.filter(Predicate.isNotNullish),
          Option.orElseSome(() => 1),
        ),
      ),
      encode: SchemaGetter.required(),
    }),
  ),
})

//     ┌─── { readonly quantity?: string | null | undefined; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity: number; }
//     ▼
type Type = typeof Product.Type

console.log(Schema.decodeUnknownSync(Product)({}))
// Output: { quantity: 1 }

console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))
// Output: { quantity: 1 }

console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))
// Output: { quantity: 1 }

console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))
// Output: { quantity: 2 }

键缺失或为 null 时的默认值

当缺失和 null 都应产生默认值、而 undefined 应被拒绝时,请使用精确可选(exact optional)的可空字段。

操作行为
解码如果输入缺失或为 null,则应用默认值
编码把输入 t: T 转换回 e: E

示例(仅在字段缺失或为 null 时应用默认值)

import { Option, Predicate, Schema, SchemaGetter } from "effect"

const Product = Schema.Struct({
  quantity: Schema.optionalKey(Schema.NullOr(Schema.FiniteFromString)).pipe(
    Schema.decodeTo(Schema.Finite, {
      decode: SchemaGetter.transformOptional((o) =>
        o.pipe(
          Option.filter(Predicate.isNotNull),
          Option.orElseSome(() => 1),
        ),
      ),
      encode: SchemaGetter.required(),
    }),
  ),
})

//     ┌─── { readonly quantity?: string | null; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity: number; }
//     ▼
type Type = typeof Product.Type

console.log(Schema.decodeUnknownSync(Product)({}))
// Output: { quantity: 1 }

console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))
// Output: { quantity: 1 }

console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))
// Output: { quantity: 2 }

console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))
/*
throws:
SchemaError: Expected string | null
  at ["quantity"]
*/

作为 Option 的可选字段

处理可选字段时,你可能希望把它们当作 Option 值来处理。这种方式让你能够显式地管理字段的存在或缺失,而不必依赖 undefined 或 null。

使用 Option 类型的基本可选字段

Schema.OptionFromOptional 会把缺失或为 undefined 的字段转换为 Option.none(),把已存在的值转换为 Option.some()。

解码
InputOutput
<missing value>transforms to Option.none()
undefinedtransforms to Option.none()
e: Etransforms to Option.some(t: T)
编码
InputOutput
Option.none()transforms to <missing value>
Option.some(t: T)transforms back to e: E

示例(把可选字段作为 Option 处理)

import { Schema } from "effect"

const Product = Schema.Struct({
  quantity: Schema.OptionFromOptional(Schema.FiniteFromString),
})

//     ┌─── { readonly quantity?: string | undefined; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity: Option<number>; }
//     ▼
type Type = typeof Product.Type

console.log(Schema.decodeUnknownSync(Product)({}))
// Output: { quantity: { _id: 'Option', _tag: 'None' } }

console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))
// Output: { quantity: { _id: 'Option', _tag: 'None' } }

console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))
// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }

精确可选键作为 Option

Schema.OptionFromOptionalKey 会把缺失的键转换为 Option.none(),同时在键存在时拒绝 undefined。

解码
InputOutput
<missing value>transforms to Option.none()
undefinedSchemaError
e: Etransforms to Option.some(t: T)
编码
InputOutput
Option.none()transforms to <missing value>
Option.some(t: T)transforms back to e: E

示例(在可选字段作为 Option 时使用精确性)

import { Schema } from "effect"

const Product = Schema.Struct({
  quantity: Schema.OptionFromOptionalKey(Schema.FiniteFromString),
})

//     ┌─── { readonly quantity?: string; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity: Option<number>; }
//     ▼
type Type = typeof Product.Type

console.log(Schema.decodeUnknownSync(Product)({}))
// Output: { quantity: { _id: 'Option', _tag: 'None' } }

console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))
// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }

console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))
/*
throws:
SchemaError: Expected string
  at ["quantity"]
*/

可空的可选字段

Schema.OptionFromOptionalNullOr 还会把 null 视为 Option.none()。

解码
InputOutput
<missing value>transforms to Option.none()
undefinedtransforms to Option.none()
nulltransforms to Option.none()
e: Etransforms to Option.some(t: T)
编码
InputOutput
Option.none()transforms to <missing value>
Option.some(t: T)transforms back to e: E

示例(在可选字段作为 Option 时把 null 视为缺失值)

import { Schema } from "effect"

const Product = Schema.Struct({
  quantity: Schema.OptionFromOptionalNullOr(Schema.FiniteFromString),
})

//     ┌─── { readonly quantity?: string | null | undefined; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity: Option<number>; }
//     ▼
type Type = typeof Product.Type

console.log(Schema.decodeUnknownSync(Product)({}))
// Output: { quantity: { _id: 'Option', _tag: 'None' } }

console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))
// Output: { quantity: { _id: 'Option', _tag: 'None' } }

console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))
// Output: { quantity: { _id: 'Option', _tag: 'None' } }

console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))
// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }

可空的精确可选键作为 Option

当缺失和 null 都应变成 Option.none()、而 undefined 应被拒绝时,可以把 Schema.optionalKey、Schema.NullOr 和 SchemaGetter.transformOptional 组合起来。

解码
InputOutput
<missing value>transforms to Option.none()
undefinedSchemaError
nulltransforms to Option.none()
e: Etransforms to Option.some(t: T)
编码
InputOutput
Option.none()transforms to <missing value>
Option.some(t: T)transforms back to e: E

示例(在可选字段作为 Option 时使用精确性并把 null 视为缺失值)

import { Option, Predicate, Schema, SchemaGetter } from "effect"

const Product = Schema.Struct({
  quantity: Schema.optionalKey(Schema.NullOr(Schema.FiniteFromString)).pipe(
    Schema.decodeTo(Schema.Option(Schema.Finite), {
      decode: SchemaGetter.transformOptional((o) =>
        Option.some(o.pipe(Option.filter(Predicate.isNotNull))),
      ),
      encode: SchemaGetter.transformOptional(Option.flatten),
    }),
  ),
})

//     ┌─── { readonly quantity?: string | null; }
//     ▼
type Encoded = typeof Product.Encoded

//     ┌─── { readonly quantity: Option<number>; }
//     ▼
type Type = typeof Product.Type

console.log(Schema.decodeUnknownSync(Product)({}))
// Output: { quantity: { _id: 'Option', _tag: 'None' } }

console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))
// Output: { quantity: { _id: 'Option', _tag: 'None' } }

console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))
// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }

console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))
/*
throws:
SchemaError: Expected string | null
  at ["quantity"]
*/

可选字段的转换

从可选到可选

使用 Schema.decodeTo 搭配 SchemaGetter.transformOptional,可以把编码侧的可选字段转换为解码侧的可选字段。这样,转换逻辑就能自行决定该键在任意一侧是否存在。

一个常见用例是把某个特定的编码值(例如空字符串)视为解码输出中缺失的字段。

解码和编码 getter 接收的是一个 Option:None 表示键缺失,返回 None 就会把它从输出中省略。

示例(从输出中省略空字符串)

考虑一个 string 类型的可选字段:输入中的空字符串应当从输出中移除。

import { Option, Schema, SchemaGetter } from "effect"

const schema = Schema.Struct({
  nonEmpty: Schema.optionalKey(Schema.String).pipe(
    Schema.decodeTo(Schema.optionalKey(Schema.String), {
      //         ┌─── Option<string>
      //         ▼
      decode: SchemaGetter.transformOptional((maybeString) => {
        if (Option.isNone(maybeString)) {
          // If `maybeString` is `None`, the field is absent in the input.
          // Return Option.none() to omit it in the output.
          return Option.none()
        }
        // Extract the value from the `Some` instance
        const value = maybeString.value
        if (value === "") {
          // Treat empty strings as missing in the output
          // by returning Option.none().
          return Option.none()
        }
        // Include non-empty strings in the output.
        return Option.some(value)
      }),
      // In the encoding phase, you can decide to process the field
      // similarly to the decoding phase or use a different logic.
      // Here, the logic is left unchanged.
      //
      //         ┌─── Option<string>
      //         ▼
      encode: SchemaGetter.transformOptional((maybeString) => maybeString),
    }),
  ),
})

// Decoding examples

const decode = Schema.decodeUnknownSync(schema)

console.log(decode({}))
// Output: {}
console.log(decode({ nonEmpty: "" }))
// Output: {}
console.log(decode({ nonEmpty: "a non-empty string" }))
// Output: { nonEmpty: 'a non-empty string' }

// Encoding examples

const encode = Schema.encodeSync(schema)

console.log(encode({}))
// Output: {}
console.log(encode({ nonEmpty: "" }))
// Output: { nonEmpty: '' }
console.log(encode({ nonEmpty: "a non-empty string" }))
// Output: { nonEmpty: 'a non-empty string' }

你可以用 Option.filter 简化解码逻辑,它以简洁的方式过滤掉不需要的值。

示例(使用 Option.filter 进行解码)

import { identity, Option, Schema, SchemaGetter } from "effect"

const schema = Schema.Struct({
  nonEmpty: Schema.optionalKey(Schema.String).pipe(
    Schema.decodeTo(Schema.optionalKey(Schema.String), {
      decode: SchemaGetter.transformOptional(Option.filter((s) => s !== "")),
      encode: SchemaGetter.transformOptional(identity),
    }),
  ),
})

从可选到必需

在编码侧使用可选 schema,在解码侧使用必需 schema。当编码侧的键缺失时,SchemaGetter.transformOptional 可以提供一个值;在编码期间,它也可以省略选定的值。

示例(把 null 设为缺失字段的默认值)

这个例子在编码字段缺失时提供一个 null 值。在编码期间,解码后的 null 值会省略该字段。

import { Option, Schema, SchemaGetter } from "effect"

const schema = Schema.Struct({
  nullable: Schema.optionalKey(
    // Input schema for an optional string
    Schema.String,
  ).pipe(
    Schema.decodeTo(
      // Output schema allowing null or string
      Schema.NullOr(Schema.String),
      {
        //         ┌─── Option<string>
        //         ▼
        decode: SchemaGetter.transformOptional((maybeString) => {
          if (Option.isNone(maybeString)) {
            // If `maybeString` is `None`, the field is absent in the input.
            // Return `null` as the default value for the output.
            return Option.some(null)
          }
          // Extract the value from the `Some` instance
          // and use it as the output.
          return Option.some(maybeString.value)
        }),
        // During encoding, treat `null` as an absent field
        //
        //         ┌─── string | null
        //         ▼
        encode: SchemaGetter.transformOptional((maybeStringOrNull) =>
          Option.flatMap(maybeStringOrNull, (stringOrNull) =>
            stringOrNull === null
              ? // Omit the field by returning `None`
                Option.none()
              : // Include the field by returning `Some`
                Option.some(stringOrNull),
          ),
        ),
      },
    ),
  ),
})

// Decoding examples

const decode = Schema.decodeUnknownSync(schema)

console.log(decode({}))
// Output: { nullable: null }
console.log(decode({ nullable: "a value" }))
// Output: { nullable: 'a value' }

// Encoding examples

const encode = Schema.encodeSync(schema)

console.log(encode({ nullable: "a value" }))
// Output: { nullable: 'a value' }
console.log(encode({ nullable: null }))
// Output: {}

你可以用 Option.getOrElse 和 Option.liftPredicate 来精简解码与编码逻辑,写出简洁易读的转换。

示例(使用 Option.getOrElse 和 Option.liftPredicate)

import { Option, Schema, SchemaGetter } from "effect"

const schema = Schema.Struct({
  nullable: Schema.optionalKey(Schema.String).pipe(
    Schema.decodeTo(Schema.NullOr(Schema.String), {
      decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)),
      encode: SchemaGetter.transformOptional(
        Option.filter((value) => value !== null),
      ),
    }),
  ),
})

从必需到可选

在编码侧使用必需 schema,在解码侧使用可选 schema。这种转换可以省略选定的解码值,并且必须在编码期间恢复出一个必需的值。

示例(把空字符串视为缺失值)

在这个例子中,name 字段是必需的,但如果它的值是空字符串,就会被当作可选。解码时,name 中的空字符串被视为缺失;编码时则保证一定有一个值(如果 name 缺失,就用空字符串作为默认值)。

import { Option, Schema, SchemaGetter } from "effect"

const schema = Schema.Struct({
  name: Schema.String.pipe(
    Schema.decodeTo(Schema.optionalKey(Schema.String), {
      //         ┌─── Option<string>
      //         ▼
      decode: SchemaGetter.transformOptional((maybeString) =>
        Option.flatMap(maybeString, (string) => {
          // Treat empty string as a missing value
          if (string === "") {
            // Omit the field by returning `None`
            return Option.none()
          }
          // Otherwise, return the string as is
          return Option.some(string)
        }),
      ),
      //         ┌─── Option<string>
      //         ▼
      encode: SchemaGetter.transformOptional((maybeString) => {
        // Check if the field is missing
        if (Option.isNone(maybeString)) {
          // Provide an empty string as default
          return Option.some("")
        }
        // Otherwise, return the string as is
        return maybeString
      }),
    }),
  ),
})

// Decoding examples

const decode = Schema.decodeUnknownSync(schema)

console.log(decode({ name: "John" }))
// Output: { name: 'John' }
console.log(decode({ name: "" }))
// Output: {}

// Encoding examples

const encode = Schema.encodeSync(schema)

console.log(encode({ name: "John" }))
// Output: { name: 'John' }
console.log(encode({}))
// Output: { name: '' }

你可以用 Option.liftPredicate 和 Option.getOrElse 来精简解码与编码逻辑,写出简洁易读的转换。

示例(使用 Option.liftPredicate 和 Option.getOrElse)

import { Option, Schema, SchemaGetter } from "effect"

const schema = Schema.Struct({
  name: Schema.String.pipe(
    Schema.decodeTo(Schema.optionalKey(Schema.String), {
      decode: SchemaGetter.transformOptional((maybeString) =>
        Option.flatMap(
          maybeString,
          Option.liftPredicate((s) => s !== ""),
        ),
      ),
      encode: SchemaGetter.transformOptional((maybeString) =>
        Option.some(Option.getOrElse(maybeString, () => "")),
      ),
    }),
  ),
})

扩展 schema

Struct schema 会暴露自己的 fields,你可以把它展开到新的 struct 中,也可以用 Schema.fieldsAssign 来扩展。Union 会暴露 mapMembers,因此可以把同一个字段操作应用到每个 struct 成员上。

Retaining Struct Type with Field Spreading

对 ...Struct.fields 使用字段展开时,schema 会保持 Struct 类型, 这样你就可以继续访问 fields 属性来做进一步的修改。

展开 Struct 的字段

Struct 通过 fields 属性提供对其字段的访问,这让你可以扩展已有的 struct:既能添加额外的字段,也能把多个 struct 的字段合并起来。

示例(添加新字段)

import { Schema } from "effect"

const Original = Schema.Struct({
  a: Schema.String,
  b: Schema.String,
})

const Extended = Schema.Struct({
  ...Original.fields,
  // Adding new fields
  c: Schema.String,
  d: Schema.String,
})

//     ┌─── {
//     |      readonly a: string;
//     |      readonly b: string;
//     |      readonly c: string;
//     |      readonly d: string;
//     |    }
//     ▼
type Type = typeof Extended.Type

示例(添加额外的索引签名)

import { Schema } from "effect"

const Original = Schema.Struct({
  a: Schema.String,
  b: Schema.String,
})

const Extended = Schema.StructWithRest(
  Schema.Struct(Original.fields),
  // Adding an index signature
  [Schema.Record(Schema.String, Schema.String)],
)

//     ┌─── {
//     │      readonly [x: string]: string;
//     |      readonly a: string;
//     |      readonly b: string;
//     |    }
//     ▼
type Type = typeof Extended.Type

示例(合并多个 struct 的字段)

import { Schema } from "effect"

const Struct1 = Schema.Struct({
  a: Schema.String,
  b: Schema.String,
})

const Struct2 = Schema.Struct({
  c: Schema.String,
  d: Schema.String,
})

const Extended = Schema.Struct({
  ...Struct1.fields,
  ...Struct2.fields,
})

//     ┌─── {
//     |      readonly a: string;
//     |      readonly b: string;
//     |      readonly c: string;
//     |      readonly d: string;
//     |    }
//     ▼
type Type = typeof Extended.Type

fieldsAssign 函数

Schema.fieldsAssign(fields) 是 struct.mapFields(Struct.assign(fields)) 的简洁写法。你可以直接在 struct 上使用它,也可以把它映射到 union 的每个成员上。

示例(为每个 union 成员添加字段)

import { Schema, Tuple } from "effect"

const Struct = Schema.Struct({
  a: Schema.String,
})

const UnionOfStructs = Schema.Union([
  Schema.Struct({ b: Schema.String }),
  Schema.Struct({ c: Schema.String }),
])

const Extended = UnionOfStructs.mapMembers(
  Tuple.map(Schema.fieldsAssign(Struct.fields)),
)

//     ┌─── {
//     |        readonly a: string;
//     |    } & ({
//     |        readonly b: string;
//     |    } | {
//     |        readonly c: string;
//     |    })
//     ▼
type Type = typeof Extended.Type

重命名属性

在定义时重命名属性

如果希望在编码表示中使用不同的键,请在定义 struct 之后应用 Schema.encodeKeys。

示例(重命名必需属性)

import { Schema } from "effect"

const schema = Schema.Struct({
  a: Schema.String,
  b: Schema.Finite,
}).pipe(Schema.encodeKeys({ a: "c" }))

//     ┌─── { readonly c: string; readonly b: number; }
//     ▼
type Encoded = typeof schema.Encoded

//     ┌─── { readonly a: string; readonly b: number; }
//     ▼
type Type = typeof schema.Type

console.log(Schema.decodeUnknownSync(schema)({ c: "c", b: 1 }))
// Output: { a: "c", b: 1 }

示例(重命名可选属性)

import { Schema } from "effect"

const schema = Schema.Struct({
  a: Schema.optional(Schema.String),
  b: Schema.Finite,
}).pipe(Schema.encodeKeys({ a: "c" }))

//     ┌─── { readonly b: number; readonly c?: string | undefined; }
//     ▼
type Encoded = typeof schema.Encoded

//     ┌─── { readonly a?: string | undefined; readonly b: number; }
//     ▼
type Type = typeof schema.Type

console.log(Schema.decodeUnknownSync(schema)({ c: "c", b: 1 }))
// Output: { a: 'c', b: 1 }

console.log(Schema.decodeUnknownSync(schema)({ b: 1 }))
// Output: { b: 1 }

重命名已有 schema 的属性

对于已有的 struct,用 mapFields 重命名其解码后的字段,然后用 Schema.encodeKeys 在编码表示中保留原来的名字。对于 union,把同样的操作应用到每个成员上。

示例(重命名 struct schema 中的属性)

import { Schema, Struct } from "effect"

const Original = Schema.Struct({
  c: Schema.String,
  b: Schema.Finite,
})

// Renaming the "c" property to "a"
//
//
//      ┌─── Struct<{
//      |      readonly a: string;
//      |      readonly b: number;
//      |    }>
//      ▼
const Renamed = Original.mapFields((fields) => ({
  a: fields.c,
  ...Struct.omit(fields, ["c"]),
})).pipe(Schema.encodeKeys({ a: "c" }))

console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", b: 1 }))
// Output: { a: "c", b: 1 }

示例(重命名 union schema 中的属性)

import { Schema } from "effect"

const Original = Schema.Union([
  Schema.Struct({
    a: Schema.String,
    b: Schema.Finite,
  }),
  Schema.Struct({
    a: Schema.String,
    d: Schema.Boolean,
  }),
])

// Use "c" for "a" in the encoded representation of every member
const Renamed = Original.mapMembers(
  ([first, second]) =>
    [
      first.pipe(Schema.encodeKeys({ a: "c" })),
      second.pipe(Schema.encodeKeys({ a: "c" })),
    ] as const,
)

console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", b: 1 }))
// Output: { a: "c", b: 1 }

console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", d: false }))
// Output: { a: "c", d: false }

递归 schema

Schema.suspend 函数用于定义引用自身的 schema,例如递归数据结构中的 schema。

示例(自引用 schema)

在这个例子中,Category schema 通过 subcategories 字段引用自身,该字段是一个由 Category 对象组成的数组。

import { Schema } from "effect"

interface Category {
  readonly name: string
  readonly subcategories: ReadonlyArray<Category>
}

const Category = Schema.Struct({
  name: Schema.String,
  subcategories: Schema.Array(
    Schema.suspend((): Schema.Codec<Category> => Category),
  ),
})
Correct Inference

必须定义 Category 类型并添加显式的类型注解,否则 TypeScript 将难以正确推断 类型。没有这个注解,你可能会遇到如下错误信息:

示例(类型推断错误)

import { Schema } from "effect"

// @errors: 7022
const Category = Schema.Struct({
  name: Schema.String,
  // @errors: 7022 7024
  subcategories: Schema.Array(Schema.suspend(() => Category)),
})

简化 schema 定义的实用模式

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

示例(分离递归字段)

import { Schema } from "effect"

const fields = {
  name: Schema.String,
  // ...other fields as needed
}

// Define an interface for the Category schema,
// extending the Type of the defined fields
interface Category extends Schema.Struct.Type<typeof fields> {
  // Define `subcategories` using recursion
  readonly subcategories: ReadonlyArray<Category>
}

const Category = Schema.Struct({
  ...fields, // Spread in the base fields
  subcategories: Schema.Array(
    // Define `subcategories` using recursion
    Schema.suspend((): Schema.Codec<Category> => Category),
  ),
})

相互递归的 schema

你也可以使用 Schema.suspend 创建相互递归的 schema,即两个 schema 互相引用。在下面的例子中,Expression 和 Operation 通过相互引用构成一棵简单的算术表达式树。

示例(定义相互递归的 schema)

import { Schema } from "effect"

interface Expression {
  readonly type: "expression"
  readonly value: number | Operation
}

interface Operation {
  readonly type: "operation"
  readonly operator: "+" | "-"
  readonly left: Expression
  readonly right: Expression
}

const Expression = Schema.Struct({
  type: Schema.Literal("expression"),
  value: Schema.Union([
    Schema.Finite,
    Schema.suspend((): Schema.Codec<Operation> => Operation),
  ]),
})

const Operation = Schema.Struct({
  type: Schema.Literal("operation"),
  operator: Schema.Literals(["+", "-"]),
  left: Expression,
  right: Expression,
})

Encoded 与 Type 不同的递归类型

定义 Encoded 类型与 Type 类型不同的递归 schema 会再增加一层复杂度。在这种情况下,我们需要定义两个 interface:一个用于 Type 类型(如前所见),另一个用于 Encoded 类型。

示例(Encoded 与 Type 定义不同的递归 schema)

让我们看一个 id 字段由 Schema.FiniteFromString 定义的例子。 它的 Type 是 number,而它的 Encoded 类型是 string。 当我们把这个字段添加到 Category schema 时,TypeScript 会报错:

import { Schema } from "effect"

const fields = {
  id: Schema.FiniteFromString,
  name: Schema.String,
}

interface Category extends Schema.Struct.Type<typeof fields> {
  readonly subcategories: ReadonlyArray<Category>
}

const Category = Schema.Struct({
  ...fields,
  subcategories: Schema.Array(
    // @errors: 2322
    Schema.suspend((): Schema.Codec<Category> => Category),
  ),
})

这样写会失败,因为 Schema.Codec<Category> 会把编码类型默认为 Category。递归的边还必须指定 CategoryEncoded:

import { Schema } from "effect"

const fields = {
  id: Schema.FiniteFromString,
  name: Schema.String,
}

interface Category extends Schema.Struct.Type<typeof fields> {
  readonly subcategories: ReadonlyArray<Category>
}

interface CategoryEncoded extends Schema.Struct.Encoded<typeof fields> {
  readonly subcategories: ReadonlyArray<CategoryEncoded>
}

const Category = Schema.Struct({
  ...fields,
  subcategories: Schema.Array(
    Schema.suspend((): Schema.Codec<Category, CategoryEncoded> => Category),
  ),
})