自定义 DSL 类型
自定义 DSL 类型用来把你项目里的业务类型注册成 DSL 类型。例如把“租户 ID”注册成 tenant-id 后,业务 schema 里就可以直接写:
const schema = s({
compact: 'tenant-id:corp!',
named: s('tenant-id:corp!').label('租户'),
typed: s.tenantId('corp').label('租户').require()
});
这页只解决三件事:
- 怎么定义一次业务类型。
- 三种入口分别适合什么场景。
- 参数、必填、枚举、约束这些容易混淆的语法怎么分清。
这三种入口必须产出等价 schema。不要为同一个业务类型维护三份定义。
动态值怎么写
动态值也可以写进纯 DSL,但要记住:模板字符串只是 JavaScript 先拼出一个普通字符串,schema-dsl 解析的仍然是最终字符串。
const scope = currentUser.companyId ? 'corp' : 'tenant';
const schema = s({
tenant: `tenant-id:${scope}!`
});
如果 scope 是 'corp',最终交给 schema-dsl 的就是 tenant-id:corp!;如果是 'tenant',最终就是 tenant-id:tenant!。
如果变量名刚好叫 params,但它本身就是 'corp' 这样的字符串,那么 `tenant-id:${params}!` 也能工作。文档不推荐的是把整个配置对象直接插进去。
所以文档里可以展示 `tenant-id:${scope}!` 这类示例,但不能把整个对象直接塞进 ${...}。如果值来自用户输入,先白名单校验再拼 DSL。
先定义一个业务类型
一个扩展定义可以理解成“把业务类型翻译成 JSON Schema 的规则”。下面这个例子把 tenant-id 翻译成字符串规则,并根据参数 corp / tenant 选择不同前缀。
import { registerExtensions } from 'schema-dsl/pure';
export const s = registerExtensions([
{
literal: 'tenant-id',
factoryName: 'tenantId',
segmentMode: 'params',
params: {
scope: {
kind: 'enum',
values: ['tenant', 'corp'],
default: 'tenant',
description: '标识命名空间'
}
},
schema({ scope }) {
return {
type: 'string',
pattern: scope === 'corp'
? '^corp_[a-z0-9]+$'
: '^tenant_[a-z0-9]+$'
};
}
}
] as const);
第一次读这段代码时,先记住这五个字段:
只写 TypeScript 签名,例如 tenantId(scope: 'tenant' | 'corp') 不够,因为 TypeScript 只在开发时提供提示,运行时不会保留这个类型。运行时仍然需要 params 来解析 DSL 字符串、校验非法值、应用默认值和生成文档。
扩展定义字段
需要完整配置时,再看这张表:
命名规则
如果 literal 与内置类型冲突,或者 factoryName 与内置工厂、builder 方法、已有扩展冲突,应该直接报错并提示冲突来源。不要静默覆盖,因为覆盖会让同一个 DSL 在不同模块里含义不同。
参数配置
参数是冒号后面的短值。它应该短小、可序列化,适合写进 DSL 字符串:
const schema = s({
tenant: 'tenant-id!',
corpTenant: 'tenant-id:corp!',
corpOwner: s('tenant-id:corp!').label('负责人'),
corpAdmin: s.tenantId('corp').label('管理员').require()
});
这里的 corp 来自 params.scope,不是字段枚举值,也不是普通字符串约束。
对象、函数、正则、多字段选项等复杂值不要塞进 DSL 字符串,改用 s.xxx(...) 的 factory 参数。下面这个例子把参数消费路径完整展开:prefix 和 length 最终都会进入正则。
import { registerExtensions } from 'schema-dsl/pure';
// 扩展定义:prefix 和 length 都会进入 schema 生成逻辑
const s = registerExtensions([
{
literal: 'prefixed-code',
factoryName: 'prefixedCode',
segmentMode: 'params',
params: {
prefix: {
kind: 'string',
default: 'USR',
description: '编号前缀'
},
length: {
kind: 'number',
default: 8,
factoryOnly: true,
description: '编号随机部分长度'
}
},
schema({ prefix = 'USR', length = 8 }) {
return {
type: 'string',
pattern: `^${prefix}_[A-Z0-9]{${length}}$`
};
}
}
] as const);
const schema = s({
compact: 'prefixed-code:INV!', // prefix = 'INV',length 使用默认值 8
precise: s.prefixedCode({ prefix: 'INV', length: 8 }).require() // factory 可以显式传完整配置
});
这段代码的结果很直接:
这样用户能看到每个参数在哪里被用到,不需要猜 length 这种配置项有什么作用。
params 字段说明
params 的每个 key 是参数名,每个 value 是参数声明。它既用于解析 'tenant-id:corp!',也用于约束 s.tenantId('corp') 的参数。
先分清两类名字,否则很容易把 scope、min、max、length 看成 schema-dsl 内置字段:
例如下面这些都是“参数名”,含义由扩展作者在 schema(...) 里决定:
params: {
scope: {
kind: 'enum',
values: ['tenant', 'corp'],
default: 'tenant',
description: '租户 ID 的命名空间'
},
length: {
kind: 'number',
default: 8,
factoryOnly: true,
description: '随机部分长度,只通过 s.xxx(...) 传入'
}
}
scope、min、max、length 本身没有固定魔法。真正的效果来自 schema(...):
schema({ min, max }) {
return {
type: 'number',
minimum: min,
maximum: max
};
}
params: {
scope: {
kind: 'enum',
values: ['tenant', 'corp'],
default: 'tenant',
required: false,
description: '标识命名空间'
}
}
参数类型
复杂值不要塞进 DSL 字符串:
DSL 参数如何映射
把 DSL 字符串看成三段会更容易:
tenant-id : corp !
类型名 参数 必填标记
解析顺序如下:
- 先看最后的
! / ?:它们只表示字段必填或可选,不属于参数。
- 再看类型名,例如
tenant-id,用它找到扩展定义。
- 如果
segmentMode: 'params',冒号后的内容按 params 声明解析。
- 参数模式只处理一个短值;范围和比较符使用已有约束语法,不使用逗号拆多参数。
- 参数缺省时使用
default;没有默认值且 required: true 时抛错。
- 多余参数、非法枚举、非法数字、无法转换的布尔值都应该抛出可读错误。
示例:
范围值怎么写
范围值不要写成逗号分隔。schema-dsl 当前已有 range 语法,用户已经会把 number:18-65 理解成最小 18、最大 65,自定义类型也应该沿用这个规则。
先看用户会写什么:
const schema = s({
age: 'age-range:18-65!'
});
这对用户来说只表示:年龄最小 18,最大 65。
扩展作者把这个类型声明成约束型扩展:
const s = registerExtensions([
{
literal: 'age-range',
factoryName: 'ageRange',
segmentMode: 'constraint',
schema: { type: 'number' },
factory(min: number, max: number) {
return `age-range:${min}-${max}`;
}
}
] as const);
这段配置的意思是:冒号后的 18-65 交给现有 range parser,而不是交给 params 自己拆。
const schema = s({
compact: 'age-range:18-65!',
named: s('age-range:18-65!').label('年龄'),
typed: s.ageRange(18, 65).label('年龄').require()
});
当前 age-range:18,65! 不支持。逗号在 schema-dsl 里主要用于 enum: 这类枚举列表,例如 enum:number:1,2,3!,不要把它作为通用多参数分隔符。
如果一个自定义类型真的需要多个彼此无关的参数,优先用 s.xxx({ ... }) 或 s.xxx(a, b),不要把它塞进一个紧凑 DSL 字符串里。
容易混淆的 DSL 语法
自定义 DSL 类型不能改变已有 DSL 语义。遇到下面这些写法时,先按表格区分它们:
必填、可选和 key 级 required
! / ? 不是扩展参数,而是字段 required 语义:
const schema = s({
optionalA: 'tenant-id',
optionalB: 'tenant-id?',
requiredA: 'tenant-id!',
'requiredB!': 'tenant-id?'
});
字段枚举和参数枚举
这两类 enum 必须分清:
const schema = s({
status: 'active|inactive!',
level: 'enum:number:1,2,3!',
tenant: 'tenant-id:corp!',
tenantLimited: s('tenant-id:corp!').enum('corp_admin', 'corp_owner')
});
每个扩展声明冒号段如何解释:
segmentMode 三种具体例子
segmentMode 只回答一个问题:用户写了冒号后,冒号后的内容到底按什么规则读。
segmentMode: 'none'
适合没有参数的静态业务类型。用户只能写类型名和必填/可选标记,不能写冒号段。
const s = registerExtensions([
{
literal: 'snowflake-id',
factoryName: 'snowflakeId',
segmentMode: 'none',
schema: {
type: 'string',
pattern: '^[0-9]{18,20}$'
}
}
] as const);
const schema = s({
id: 'snowflake-id!'
});
segmentMode: 'params'
适合 tenant-id:corp! 这种“冒号后是业务参数”的类型。
const s = registerExtensions([
{
literal: 'tenant-id',
factoryName: 'tenantId',
segmentMode: 'params',
params: {
scope: {
kind: 'enum',
values: ['tenant', 'corp'],
default: 'tenant',
description: '租户 ID 的命名空间'
}
},
schema({ scope }) {
return {
type: 'string',
pattern: scope === 'corp' ? '^corp_[a-z0-9]+$' : '^tenant_[a-z0-9]+$'
};
}
}
] as const);
segmentMode: 'constraint'
适合“冒号后是比较符、范围、等值约束”的类型。用户不用重新学习一套参数语法,直接沿用核心数字约束。
const s = registerExtensions([
{
literal: 'positive-money',
factoryName: 'positiveMoney',
segmentMode: 'constraint',
schema: {
type: 'number',
minimum: 0
}
}
] as const);
const schema = s({
price: 'positive-money:<=999!'
});
什么时候配置 segmentMode
默认不把参数和数字约束挤进同一个紧凑字符串。两者都需要时使用 builder 方法:
const schema = s({
price: s('money:usd!').min(0),
total: s.money('usd').min(0).require()
});
如果用户写成 money:usd>=0!,实现应该给出明确诊断,例如“money 使用参数模式,不能在同一冒号段里混写数值约束;请使用 s('money:usd!').min(0)”。不要静默把 usd>=0 当成参数,也不要退化成未知类型。
数字比较运算符属于核心约束
这些语法不是扩展参数:
自定义类型只有在声明 segmentMode: 'constraint' 时才应该消费这些约束。声明 segmentMode: 'params' 的扩展应把冒号段按参数解析。
发布状态和可跳过内容
普通用户定义业务类型时,只需要理解上面的 literal、factoryName、params、schema 和三种入口。
本页描述的是扩展系统的公开使用方式。请以当前安装版本的导出 API 为准;如果你在本仓库内阅读文档示例,运行前先执行本地构建。
如果你在源码或历史文档里看到直接 String 链式、编译期转换或特殊解析 hook,它们是兼容/高级能力,不是普通业务类型的主入口。
什么不是自定义 DSL 类型入口
自定义业务类型不需要自定义 base builder 链式方法:
s('string!').tenantId(); // 不属于自定义 DSL 类型模型
'string!'.tenantId(); // 不属于自定义 DSL 类型模型
已有的直接 String 链式和 transform 能力仍然是独立的兼容/作者体验工具,不应用来暴露 tenant-id 这类普通业务类型。
TypeScript 提示怎么选
三种入口的提示体验不同,这是设计上的取舍:
因此文档示例可以同时展示三种入口,但真实项目建议这样组织:
// schema-dsl.ts
import { registerExtensions } from 'schema-dsl/pure';
export const s = registerExtensions([
{
literal: 'tenant-id',
factoryName: 'tenantId',
segmentMode: 'params',
params: {
scope: {
kind: 'enum',
values: ['tenant', 'corp'],
default: 'tenant'
}
},
schema({ scope }) {
return { type: 'string', pattern: `^${scope}_[a-z0-9]+$` };
}
}
] as const);
业务代码只从本地模块导入配置后的 s:
import { s } from './schema-dsl';
const schema = s({
compact: 'tenant-id:corp!',
readable: s('tenant-id:corp!').label('租户'),
typed: s.tenantId('corp').label('租户').require()
});
直接调用 s.registerExtension(...) 和 runtime.registerExtension(...) 仍然适合运行时动态注册,但 TypeScript 不能因为一次运行时调用就自动知道 s.tenantId() 的静态类型。需要完整提示时,使用 typed 批量注册 API 或项目自己的模块增强。
Runtime 作用域
框架、插件宿主、租户、worker 和隔离测试应把同一份扩展定义注册到 runtime 实例:
import { createRuntime } from 'schema-dsl/runtime';
const runtime = createRuntime();
const runtimeS = runtime.registerExtensions([
{
literal: 'tenant-id',
factoryName: 'tenantId',
segmentMode: 'params',
params: {
scope: { kind: 'enum', values: ['tenant', 'corp'], default: 'tenant' }
},
schema({ scope }) {
return { type: 'string', pattern: scope === 'corp' ? '^corp_' : '^tenant_' };
}
}
] as const);
const schema = runtime.s({
tenant: 'tenant-id:corp!',
owner: runtimeS.tenantId('corp').require()
});
runtimeS 是注册时返回的 typed namespace。runtime 实例本身也会更新,但 TypeScript 静态 factory 提示主要来自这个返回值。
runtime 作用域需要看清这些事:
不要要求用户先手动 uninstallStringExtensions() 才能使用 s('xxx')。如果目标是无副作用,从入口选择上就应该使用 schema-dsl/pure 或 runtime。
对应示例文件
示例入口: custom-extensions.ts
说明: 示例使用声明式参数化 API。在本仓库内运行示例前,请先执行本地构建,确保 dist/ 与源码一致。