litetype
title: litetype — 字面量即 Schema 的零依赖运行时校验库 description: 写一个裸对象字面量就是一个 schema,类型靠 Infer 推、校验靠外置函数。比 Zod 少三层噪音(没有 z.、.object()、.optional()),spread/Pick/Omit 直接复用。零运行时依赖的 TS 校验库。
litetype
字面量即 Schema。 Schema 是惰性裸数据,动词全是外置函数。写像 TS interface,组合像普通 JS。
// 你脑子里想的(TS interface)
interface User {
name: string
age: number
email?: string
}
// 这个库(把 interface 直接翻译成裸对象字面量)
import { string, number, type Infer } from 'litetype'
const User = {
name: string,
age: number,
"email?": string.email(),
}
type User = Infer<typeof User> // { name: string; age: number; email?: string }
npm i litetype
对比 Zod 多出来的 3 层噪音(z.、.object()、.optional())全没了:
const User = z.object({
name: z.string(),
age: z.number(),
email: z.string().email().optional(),
})
三件事是分开的
| 怎么写 | 何时 | |
|---|---|---|
| 值 | const User = { name: string, "email?": string } |
裸字面量,普通 JS 对象 |
| 类型 | type User = Infer<typeof User> |
编译期,零运行时 |
| 校验 | parse(User, data) / safeParse(User, data) / check(User, data) |
运行时,外置函数吃 schema |
Schema 是数据不是对象,所以 JS/TS 的一切原生可用 —— 不需要 .extend()/.merge()/.pick()/.omit(),
spread 是 extend/merge,挑字段是 pick,解构 rest 是 omit。可选键("key?")在这些操作里全程保留:
const Timestamps = { createdAt: date, "deletedAt?": date }
// extend / merge —— spread
const Post = {
title: string.min(1),
...Timestamps, // 组合两个 schema
}
const Admin = {
...User,
role: literal("admin"), // 覆写字段(后者赢)
}
// pick —— 手挑字段
const PublicUser = { name: User.name, "email?": User["email?"] }
// omit —— 解构 rest
const { age: _, ...UserNoAge } = User // UserNoAge 去掉 age,其余原样
Infer<typeof UserNoAge> 照常推断,可选键不丢 —— 因为这些都是普通 JS 对象操作,schema 只是值。
校验
import { string, number, parse, safeParse, check } from 'litetype'
const User = { name: string.min(1), age: number.min(0), "email?": string.email() }
parse(User, input) // 通过返回 typed data,失败 throw SchemaError
const r = safeParse(User, input) // { success: true, data } | { success: false, error }
if (check(User, input)) input.name // 类型守卫,input 收窄为 User
失败收集整棵树的所有错误(不在第一个错误处中断),每条 Issue 带 path + message。
内置 check 都接可选的自定义 message(末参),给了就覆盖默认错信息:
string.min(8, '至少 8 个字符') // string.min/max/length/email/url/uuid/datetime/ip/includes/startsWith/endsWith/regex
number.min(0, '不能为负') // number.min/max/int/finite/positive/nonnegative/multipleOf
date.min(epoch, '太早了') // date.min/max
uuid/datetime/ip(IPv4+IPv6)/email 均词法扫描判定(禁正则);regex 是唯一例外(校验用户提供的 pattern 对用户数据,不是解析结构化文本)。
每条 Issue 还带机读 code('invalid_type' | 'too_small' | 'too_big' | 'invalid_string' | 'invalid_value' | 'not_multiple_of' | 'unrecognized_key' | 'invalid_union' | 'custom'),消费侧按 code 分支,不靠匹配 message 文本。
给终端用户看错误 = 消费侧投影,不是库的职责。 库给的是结构化 error.issues({ path, message }[]),
按 path 摆到对应字段即可。要 i18n / 自定义文案:默认 message 是英文 dev-facing,逐 check 传中文末参覆盖(上面那行),
或在消费侧按 path 查自己的文案表 —— 库不内置 locale catalog,那是你的 UI 层一个 Record 的事:
const r = safeParse(User, input)
if (!r.success) {
const fieldErrors: Record<string, string> = {}
for (const { path, message } of r.error.issues)
fieldErrors[path.join('.')] = message // 摆到表单字段旁;message 已是你定的文案
}
默认忽略多余 key;要拒绝就 strict 收紧:
import { strict } from 'litetype'
const Exact = strict({ name: string, "age?": number })
parse(Exact, { name: "Ann", role: "x" }) // FAIL role: unexpected key
strict 只作用本层,类型上透明(Infer<strict(S)> === Infer<S>)。
refine / transform
refine 加自定义谓词(不改类型);transform 校验后变换值(Output 可异于 Input)。
refine/transform/default 是所有 schema 的方法 —— string/number 与 array/union/record 等复合节点同构持有,可任意链式:
import { string, number, array, union, literal } from 'litetype'
const Even = number.refine(n => n % 2 === 0, 'must be even') // 链式,不污染类型
const Trimmed = string.transform(s => s.trim()) // string → string
const Length = string.transform(s => s.length) // string → number,Output≠Input
parse(Length, 'hello') // 5(产出变换后的值)
array(string).transform(xs => xs.join(',')) // 复合上也能 transform
array(number).refine(xs => xs.length > 0, 'non-empty') // 复合上也能 refine
string.transform(s => s.length).transform(n => n * 2) // 链式:方法在产出节点上仍可用
裸 shape({name: string})无方法,对它整体做 transform 用自由函数 transform(shape, fn)。
校验失败时不调 transform fn(值不可信)。无变换的字段/元素保持引用,
parse 成功返回原对象(只在真正变换处浅拷贝,多余 key 透传不剥离)。
Infer<S> 取 Output;新增 InferInput<S> 取 Input(transform 的入参类型)。
default
default 与 transform 互为镜像:transform 让 Output 偏离 Input,default 让 Input 偏离 Output——缺值(undefined)时填默认值,Input 端可省略,Output 端必有:
import { string, number, array } from 'litetype'
const Role = string.default('user')
parse(Role, undefined) // 'user'(缺则填)
parse(Role, 'admin') // 'admin'(给则校验)
parse(Role, 42) // FAIL(default 只兜「缺」,不兜「坏」)
// 对象字段 default:缺 key 自动回填,无需写 "key?"
const Cfg = { name: string, role: string.default('user'), retries: number.default(3) }
parse(Cfg, { name: 'a' }) // { name: 'a', role: 'user', retries: 3 }
// Infer = { name: string; role: string; retries: number } ← Output 必有
// InferInput = { name: string; role?: string; retries?: number } ← Input 可省略
array(string).default([]) 对复合 schema 同样可用 —— .default 是所有 schema 的方法。
fallback(catch 兜底)
default 只兜「缺」,fallback 兜「坏」:校验失败时吞掉 issue、返回兜底值(zod 的 .catch())。
是自由函数 fallback(schema, value) 而非方法 —— catch 是保留字,且给 Schema 接口加 this 返回的方法会撑爆类型机递归预算(叶子方法返回类型坍缩成 never):
import { number, string, fallback, parse } from 'litetype'
parse(fallback(number, 0), 42) // 42(通过则原样产出)
parse(fallback(number, 0), 'oops') // 0(失败则兜底,不 throw)
parse(fallback(string.min(5), 'def'), 'hi') // 'def'(refinement 失败也兜)
Output 恒是合法值(Infer<S>),Input 吃任意(失败有兜底)。
optional / nullable / nullish
值层拓宽:optional 放行 undefined、nullable 放行 null、nullish 两者皆放行。本质是
union(inner, 空值) 的轻量特化(walker 一次相等判断,无 best-match)。同样是所有 schema 的方法。
import { string, number, array } from 'litetype'
parse(string.optional(), undefined) // undefined(放行)
parse(string.optional(), null) // FAIL(optional 只放行 undefined,不放行 null)
parse(string.nullable(), null) // null(放行)
parse(number.nullish(), null) // null;parse(number.nullish(), undefined) // undefined
array(string.optional()) // (string | undefined)[]
// Infer = (string | undefined)[]
与对象 key 的可选性正交:.optional() 只让值含 undefined(bio: string | undefined),
key 仍必填。要让对象 key 可省略,用 "key?" 标记 —— 一个机制,不重复:
const A = { name: string, bio: string.optional() } // { name: string; bio: string | undefined }(bio 必给,可为 undefined)
const B = { name: string, "bio?": string } // { name: string; bio?: string }(bio 可省略)
(输入侧:.optional() 字段的 InferInput key 自动可选,与 default 同 —— 见上。)
coerce
掰输入类型再校验:coerce.number() 把 "42"/42 都收成 42,coerce.boolean() 认 "true"/"false"。
Output 是目标类型,Input 是 unknown(吃任意)。要细化约束写进 inner(coerce 只负责掰值,check 留在 inner 类型干净):
import { coerce, number } from 'litetype'
parse(coerce.number(), '42') // 42(掰 string → number)
parse(coerce.number(), 'abc') // FAIL(Number('abc')→NaN,由内层 number check 报错)
parse(coerce.boolean(), 'false') // false
parse(coerce.date(), '2020-01-01') // Date 实例
parse(coerce.number(number.min(0)), '-3') // FAIL(掰成 -3 后 min(0) 拦下)
// coerce.number/boolean/date/string;Infer = 目标类型,InferInput = unknown
表单/query string/env var 这类「输入永远是字符串」的边界用它,省去手动 Number(...)。
discriminatedUnion
判别联合:按判别 key O(1) 选支,只校验那支 —— 比 union 全试 best-match 快,错误精准落在那一支:
import { discriminatedUnion, literal, number, string } from 'litetype'
const Shape = discriminatedUnion('type',
{ type: literal('circle'), radius: number },
{ type: literal('square'), side: number },
)
parse(Shape, { type: 'circle', radius: 5 }) // 只查 radius
parse(Shape, { type: 'circle', radius: 'big' }) // FAIL radius: expected number...(精准,不报 square 的事)
parse(Shape, { type: 'triangle' }) // FAIL type: invalid discriminator value "triangle"
// Infer = { type:'circle', radius:number } | { type:'square', side:number }
每支是裸 shape,判别 key 必须是 literal(...)(构造期读其值建 Map)。
partial / required
纯 shape 字符串变换,零节点:partial 给每个 key 加 ?(全可选),required 去 ?(全必填)。
与 "key?" 同一个机制,只是批量:
import { partial, required, string, number } from 'litetype'
const User = { name: string, age: number, 'email?': string.email() }
partial(User) // { 'name?', 'age?', 'email?' } —— Infer = Partial<User>
required(User) // { name, age, email } —— Infer = Required<User>
只作用裸 shape(叶/复合无 key 概念)。
JSON Schema 互转(litetype/jsonschema)
接 ajv 用户群:fromJsonSchema(doc) 把现成 JSON Schema(draft-07 子集)翻成 schema,toJsonSchema(schema) 反向出标准文档(OpenAPI / 前端表单)。走子路径 litetype/jsonschema —— 解耦,import 'litetype' 零增重,按需才引:
import { fromJsonSchema, toJsonSchema } from 'litetype/jsonschema'
import { parse } from 'litetype'
// JSON Schema → schema → 校验真数据
const sch = fromJsonSchema({
type: 'object',
properties: { name: { type: 'string', minLength: 1 }, age: { type: 'integer', minimum: 0 } },
required: ['name', 'age'],
})
parse(sch, { name: 'al', age: 3 })
// schema → JSON Schema
toJsonSchema({ id: string, 'nick?': string, kind: literal('user') })
// { type:'object', properties:{ id:{type:'string'}, nick:{type:'string'}, kind:{const:'user'} }, required:['id','kind'] }
支持映射:string+minLength/maxLength/format(email|uri|uuid|date-time)/pattern、number/integer+minimum/maximum/multipleOf、array/object/enum/const/oneOf|anyOf/$ref(→lazy)/additionalProperties:false(→strict)。
两个边界(文档化):
toJsonSchema反读不出 leaf 的 check 约束(min/email藏在闭包里)—— 只产基础type,约束丢失(往返保 type 不保约束)。transform/refine/coerce是运行时变换/谓词,JSON Schema 无对应 ——toJsonSchema命中即抛cannot serialize;fromJsonSchema碰allOf/if-then/patternProperties抛unsupported,不静默吞。
复合类型
import { array, union, tuple, record, literal, enum_, lazy } from 'litetype'
array(string.min(1))
union(literal("active"), literal("inactive")) // 变长参数
tuple(string, number) // 变长参数
record(number) // { [k: string]: number }
record(enum_(["admin", "user"]), number) // 两参:key 也校验(值层 string 约束),key 类型收窄
enum_(["admin", "user"]) // 数组参数
// 递归(JS 字面量无法自引用,lazy 是唯一逃逸舱)
const Tree = { value: string, "children?": array(lazy(() => Tree)) }
错误投影(flatten / treeify / prettify)
error.issues 是扁平 { path, message, code }[]。三个自由函数把它重整成消费场景要的形状 —— 动词外置,投影也外置(不学 zod 的 error.flatten() OOP)。吃 SchemaError 或裸 issues[] 都行:
import { flatten, treeify, prettify, safeParse } from 'litetype'
const r = safeParse({ name: string.min(2), tags: array(string.min(1)) }, { name: 'x', tags: ['ok', ''] })
if (!r.success) {
flatten(r.error)
// { formErrors: [], fieldErrors: { name: ['length must be >= 2'], tags: ['length must be >= 1'] } }
// —— 单层投影:空 path 进 formErrors,否则按 path[0] 归桶。表单 / API 400 体最常用(zod v3 .flatten() 同形)。
treeify(r.error)
// { errors: [], properties: { name: { errors:[...] }, tags: { errors:[], items: [null, { errors:[...] }] } } }
// —— 沿 path 下钻的镜像树,对象段进 properties、数组段进 items(稀疏,下标对齐)。深层嵌套表单精确定位用它(zod v4 treeifyError 同形)。
prettify(r.error) // 也可 prettify(r.error.issues)
// ✖ length must be >= 2\n → at name\n✖ length must be >= 1\n → at tags.1
// —— 人读多行串,日志 / CLI / 抛给开发者看。
}
生态:Standard Schema
实现了 Standard Schema ~standard 接口,自动接入 tRPC / react-hook-form / TanStack。叶子 schema(string 等)天然兼容;裸对象 shape 在生态边界用一次 standard() 包:
import { standard } from 'litetype'
t.procedure.input(standard(User))
useForm({ resolver: standardSchemaResolver(standard(User)) })
其余地方 User 永远是裸字面量。
已知边界(Phase 1)
- Schema key 中以
?结尾恒被当作可选标记 —— 数据 key 真以?结尾会歧义。 lazy对循环数据(非循环 schema)无 seen-set 守卫,会栈溢出(同 Zod)。- 多余 key 默认忽略(同 Zod 默认);
strict(shape)收紧为闭集,schema 外的 key 报unexpected key(只作用本层,嵌套需逐层strict)。 union全败时 collect-all:一条invalid_union伞 issue 内嵌每一支的失败原因(含判别字段),不挑「最像那支」谎报误导。判别联合用discriminatedUnion按判别 key O(1) 选支、错误精准。transformfn 由你保证纯函数性(无副作用);它在校验通过后才跑。default只在值缺失(undefined)时填;present-but-invalid 仍照常报错(要兜坏值用fallback)。默认值由构造期类型保证,不再过校验。coerce掰失败(Number('abc')→NaN)不另造错误路径,交内层 leaf check 自然报错。discriminatedUnion每支判别 key 必须是literal,否则构造期抛;partial/required只作用裸 shape。litetype/jsonschema是子路径按需引;toJsonSchema不反读 leaf check 约束、不序列化 transform/refine/coerce(见 JSON Schema 互转段)。- 签名写法
const User = {...}; type User = Infer<typeof User>(值与同名类型并存,TS 合法合并)会触发部分 ESLint 预设(如 antfu)的ts/no-redeclare。这是规则未覆盖 const+type 合并的误报,不是真冲突 —— 该行加// eslint-disable-line ts/no-redeclare,或换不同名(UserSchema/User)。
刻意异于 zod 的三处(不是 bug,是取舍)
number接受Infinity/-Infinity:number只判typeof === 'number'(NaN仍拒)。要排除无穷写number.finite()。zod 默认拒无穷 —— 我们让基础类型最小、约束显式叠加。default的填充值不过校验:默认值由构造期类型(default(value: Output))保证合法,运行时直接产出、不再走 inner check。zod 同样不校验 default。语义:default 兜「缺」,校验只管「给了的」。coerce.boolean严格白名单:只把字符串'true'/'false'掰成布尔,其余原样透传交 inner 校验(非布尔即报错);不学 zod 的Boolean(v)(那会把'false'、0之外一切真值化,反直觉)。要 truthy 语义自己transform。
零运行时依赖。