fp-ts Json 模块完全指南:基于 Either 的安全 JSON 解析与序列化
【免费下载链接】fp-tsFunctional programming in TypeScript项目地址: https://gitcode.com/gh_mirrors/fp/fp-ts
导读
fp-ts/Json模块(自 v2.10.0 起提供)为 TypeScript 函数式编程提供了不抛异常的 JSON 解析与序列化方案:它把 JavaScript 内置的JSON.parse/JSON.stringify包装成返回Either<unknown, Json>的纯函数,让错误成为可组合、可追踪的一等公民。读完本文,你将掌握Json、JsonArray、JsonRecord三个核心类型的准确含义,学会用parse安全解析字符串、用stringify安全序列化任意值,并能通过pipe、Either的各种组合子把它们无缝接入 fp-ts 的任意函数式管线中。
一、模块概览:为什么 JSON 操作需要"函数式"封装
JavaScript 原生的JSON.parse与JSON.stringify是可能抛异常的函数:解析一段非法 JSON 会抛出SyntaxError,序列化含循环引用的对象会抛出TypeError。在 fp-ts 的编程范式里,异常流无法被pipe、map、chain等组合子感知,会破坏类型的纯净性。
Json模块给出的答案是:用 tryCatch(位于src/Either.ts)把这两个 API 包装成返回Either<unknown, Json>的纯函数。tryCatch的核心实现非常直白(见 src/Either.ts#L1393-L1399):
export const tryCatch = <E, A>(f: LazyArg<A>, onThrow: (e: unknown) => E): Either<E, A> => { try { return right(f()) } catch (e) { return left(onThrow(e)) } }即:正常返回right(结果),任何异常都经onThrow转换后放入left。Json模块传给onThrow的是identity(见 src/Json.ts),因此错误类型就是unknown,原始异常对象(如SyntaxError、TypeError)会被原样保留在Either的左侧,供后续mapLeft精确检查。
从模块导出看,src/index.ts在第 58 行import * as json from './Json'、第 385 行将整个模块作为json命名空间导出,因此你可以用import * as J from 'fp-ts/Json'或import { json } from 'fp-ts'两种方式引入。
二、类型体系:Json / JsonArray / JsonRecord
Json模块定义了三个互相递归的类型,完整刻画了"合法 JSON 值"的形态(见 src/Json.ts#L10-L22):
export type Json = boolean | number | string | null | JsonArray | JsonRecord export interface JsonRecord { readonly [key: string]: Json } export interface JsonArray extends ReadonlyArray<Json> {}| 类型 | 定义 | 说明 |
|---|---|---|
Json | boolean \| number \| string \| null \| JsonArray \| JsonRecord | JSON 值的联合类型,即"值"层面的完整定义 |
JsonRecord | readonly [key: string]: Json | 键为字符串、值为Json的只读记录,即 JSON 对象 |
JsonArray | extends ReadonlyArray<Json> | Json元素的只读数组,即 JSON 数组 |
几个值得注意的设计细节:
- 没有
undefined、函数、Symbol、bigint:这些都不是合法 JSON 值,因而被类型系统直接排除。dtslint/Json.ts中用@ts-expect-error断言了undefined、箭头函数、Symbol()、含undefined字段的对象等都无法通过stringify<Json>的类型检查(见 dtslint/Json.ts#L9-L20)。 - 对象与数组是"只读"(readonly)形态:
JsonRecord的索引签名和JsonArray的数组类型都带readonly,与 fp-ts 一贯倡导的不可变数据结构风格一致,也方便与ReadonlyArray、ReadonlyRecord等模块对接。 - 递归结构:
Json引用JsonArray与JsonRecord,而后两者又引用Json,从而支持任意深度的嵌套 JSON(对象套数组、数组套对象……)。
三、parse:安全解析 JSON 字符串
parse将一段 JSON 字符串转换为Json类型(对应JSON.parse),其签名与实现如下:
export declare const parse: (s: string) => Either<unknown, Json> // 实现(src/Json.ts#L37) export const parse = (s: string): Either<unknown, Json> => tryCatch(() => JSON.parse(s), identity)行为语义
- 输入合法 JSON→ 返回
E.right(json),其中json已被JSON.parse反序列化为实际 JavaScript 值; - 输入非法 JSON→ 底层
JSON.parse抛出SyntaxError,被tryCatch捕获后包装为E.left(syntaxError),异常对象本身原样保留。
官方示例(可直接运行)
import * as J from 'fp-ts/Json' import * as E from 'fp-ts/Either' import { pipe } from 'fp-ts/function' assert.deepStrictEqual(pipe('{"a":1}', J.parse), E.right({ a: 1 })) assert.deepStrictEqual( pipe('{"a":}', J.parse), E.left(new SyntaxError(`Unexpected token '}', "{"a":}" is not valid JSON`)) )上面第二个断言展示了一个关键事实:解析失败的left分支携带的是真实的SyntaxError实例,其message与原生JSON.parse抛出的完全一致,便于日志记录或mapLeft分类处理。
测试验证
在 test/Json.ts#L7-L13 中,这两个场景均被测试用例覆盖:
it('parse', () => { U.deepStrictEqual(pipe('{"a":1}', _.parse), E.right({ a: 1 })) U.deepStrictEqual( pipe('{"a":}', _.parse), E.left(new SyntaxError(`Unexpected token '}', "{"a":}" is not valid JSON`)) ) })四、stringify:安全序列化任意 JavaScript 值
stringify将任意 JavaScript 值转换为 JSON 字符串(对应JSON.stringify),签名与实现如下:
export declare const stringify: <A>(a: A) => Either<unknown, string> // 实现(src/Json.ts#L60-L67) export const stringify = <A>(a: A): Either<unknown, string> => tryCatch(() => { const s = JSON.stringify(a) if (typeof s !== 'string') { throw new Error('Converting unsupported structure to JSON') } return s }, identity)行为语义
与裸用JSON.stringify相比,stringify有两个层次的保护:
- 异常捕获:
JSON.stringify遇到循环引用时会抛出TypeError: Converting circular structure to JSON,该异常被tryCatch捕获并放入left。 - 返回类型守卫:
JSON.stringify在遇到undefined、函数或Symbol作为顶层输入时,会返回undefined而非字符串。实现里显式检查typeof s !== 'string',一旦出现这种情况就抛出new Error('Converting unsupported structure to JSON'),从而保证right分支永远是真正的字符串,杜绝"看似成功实则拿到 undefined"的隐性 bug。
官方示例(可直接运行)
import * as E from 'fp-ts/Either' import * as J from 'fp-ts/Json' import { pipe } from 'fp-ts/function' assert.deepStrictEqual(J.stringify({ a: 1 }), E.right('{"a":1}')) const circular: any = { ref: null } circular.ref = circular assert.deepStrictEqual( pipe( J.stringify(circular), E.mapLeft((e) => e instanceof Error && e.message.includes('Converting circular structure to JSON')) ), E.left(true) )第二个断言展示了stringify与E.mapLeft的经典配合:序列化失败不再是静默的运行时异常,而是可以被函数式地检查、映射与分支处理。
测试验证
test/Json.ts#L15-L35 覆盖了四种典型输入:
| 输入 | 期望结果 |
|---|---|
{ a: 1 } | E.right('{"a":1}') |
| 含循环引用的对象 | E.left(...),且message含'Converting circular structure to JSON' |
类型化对象{ name: 'Giulio', age: 45 } | E.right('{"name":"Giulio","age":45}') |
undefined(顶层) | E.left(new Error('Converting unsupported structure to JSON')) |
其中最后一个用例直接验证了"返回类型守卫"分支:_.stringify(undefined as any)必须返回E.left(new Error('Converting unsupported structure to JSON'))。
类型层面的约束
虽然stringify的类型签名是<A>(a: A) => Either<unknown, string>(接受任意类型),但配合Json类型使用时可获得更强的编译期保障。dtslint/Json.ts展示了类型测试的意图:
- 以下调用必须报类型错误(
@ts-expect-error):stringify<Json>(undefined)、stringify<Json>(() => {})、stringify<Json>(Symbol())、stringify<Json>({ a: undefined })(见 dtslint/Json.ts#L9-L20); - 而合法的 JSON 形状(如
{ a: 'a', b: 1 }及其数组、展开副本)可以顺利通过(见 dtslint/Json.ts#L22-L37)。
五、组合使用:把 parse / stringify 接入函数式管线
由于parse与stringify都返回Either,它们可以无缝接入 fp-ts 的Either组合子体系。官方示例和 dtslint 用例给出了典型用法:
1. 链式校验与转换
import * as E from 'fp-ts/Either' import { pipe } from 'fp-ts/function' // 解析 → 校验 → 转换,全程类型安全 const result = pipe( '{"a":1}', J.parse, E.chain((json) => /* 在这里对 json 做进一步校验/转换,返回 Either<unknown, X> */ E.right(json)), E.map((x) => /* 转换成功后的值 */ x) )2. 用 chainFirst 挂接副作用
dtslint 中的用例pipe(E.right('a'), E.chainFirst(_.stringify))(见 dtslint/Json.ts#L39-L40)表明stringify可直接用于chainFirst:在保持左侧值不变的同时,把序列化结果作为校验步骤——若序列化失败,整个管道立即短路为left。
// $ExpectType Either<unknown, string> pipe(E.right('a'), E.chainFirst(_.stringify))3. 错误分类处理
const classify = (e: unknown): string => e instanceof SyntaxError ? `JSON 语法错误: ${e.message}` : e instanceof Error ? `其他错误: ${e.message}` : `未知错误: ${String(e)}` const safeParse = (s: string) => pipe(s, J.parse, E.mapLeft(classify))六、与其他模块的关系及版本背景
- 依赖:
Json模块仅依赖Either(tryCatch)与function(identity),依赖面极小,可放心引入(见 src/Json.ts#L4-L5)。 - 版本引入:该模块于 v2.10.0 加入(CHANGELOG 中 "add
Jsonmodule" 条目)。CHANGELOG 同时记录了后续版本中,原先分散在其他模块的Json类型、parseJSON、stringifyJSON等旧 API 被标记废弃,官方明确建议"使用Json模块替代"(见 CHANGELOG.md)。因此新代码应统一从fp-ts/Json导入。
七、小结与使用建议
fp-ts/Json模块的价值在于:把最容易"悄悄抛异常"的两个 JSON 操作,变成了显式、可组合、类型可追踪的Either计算。核心要点回顾:
Json/JsonArray/JsonRecord三个递归类型精确刻画了合法 JSON 值,天然排除undefined、函数等非法输入;parse返回Either<unknown, Json>,解析失败时左侧携带原始SyntaxError;stringify返回Either<unknown, string>,既捕获循环引用异常,又用显式检查杜绝"顶层值为 undefined 却返回非字符串"的隐患;- 二者均可通过
pipe、chain、chainFirst、mapLeft等组合子接入任意 Either 管线,错误处理从"try/catch 包围"升级为"数据流中的分支"。
推荐的使用姿势:在解析外部输入(HTTP 响应体、配置文件、本地存储读取)时,一律用J.parse+E.mapLeft显式分类错误;在序列化可能含循环引用或顶层非字符串值的数据时,使用J.stringify并在left分支统一记录日志。这样既保留了原生JSONAPI 的性能与语义,又让整个错误路径始终处于类型系统的掌控之中。
【免费下载链接】fp-tsFunctional programming in TypeScript项目地址: https://gitcode.com/gh_mirrors/fp/fp-ts
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考