已发布 上游基线 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

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

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

  • 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 已经需要的解码与编码服务。

添加解释器注解

定义一种新的数据类型时,诸如 ArbitraryFormatter 这样的 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

在上面的示例中,UserIdUsername 都是同一个类型 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.optionalSchema.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.optionalKeySchema.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.withDecodingDefaultTypeSchema.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"]
*/

带可空性的默认值

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

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

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

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 值来处理。这种方式让你能够显式地管理字段的存在或缺失,而不必依赖 undefinednull

使用 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.optionalKeySchema.NullOrSchemaGetter.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 接收的是一个 OptionNone 表示键缺失,返回 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.getOrElseOption.liftPredicate 来精简解码与编码逻辑,写出简洁易读的转换。

示例(使用 Option.getOrElseOption.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.liftPredicateOption.getOrElse 来精简解码与编码逻辑,写出简洁易读的转换。

示例(使用 Option.liftPredicateOption.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 互相引用。在下面的例子中,ExpressionOperation 通过相互引用构成一棵简单的算术表达式树。

示例(定义相互递归的 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 定义的例子。 它的 Typenumber,而它的 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),
  ),
})