npm.io
0.1.1 • Published 3d ago

litetype

Licence
MIT
Version
0.1.1
Deps
0
Size
114 kB
Vulns
0
Weekly
0

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

失败收集整棵树的所有错误(不在第一个错误处中断),每条 Issuepath + 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 放行 undefinednullable 放行 nullnullish 两者皆放行。本质是 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() 只让值含 undefinedbio: 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 都收成 42coerce.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)/patternnumber/integer+minimum/maximum/multipleOfarray/object/enum/const/oneOf|anyOf/$ref(→lazy)/additionalProperties:false(→strict)

两个边界(文档化):

  • toJsonSchema 反读不出 leaf 的 check 约束(min/email 藏在闭包里)—— 只产基础 type,约束丢失(往返保 type 不保约束)。
  • transform/refine/coerce 是运行时变换/谓词,JSON Schema 无对应 —— toJsonSchema 命中即抛 cannot serializefromJsonSchemaallOf/if-then/patternPropertiesunsupported,不静默吞。

复合类型

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) 选支、错误精准。
  • transform fn 由你保证纯函数性(无副作用);它在校验通过后才跑。
  • 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/-Infinitynumber 只判 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

零运行时依赖。

Keywords