权限系统通常使用位掩码来压缩多个布尔状态。以三位权限为例,读取权限可以记为 001,写入权限记为 010,删除权限记为 100;读取和写入组合后得到 011,底层只需要保存一个整数值。运行时通过按位与、按位或、按位异或来判断或合并权限,性能很高,存储也紧凑。但 TypeScript 在常见实现里只会把最后的组合结果推断成 number,类型系统完全无法区分 011 与 111 分别具备哪些权限。这个缺口让权限错误只能在运行时暴露。要补上它,可以把二进制位本身搬进类型层,让编译器参与位运算和权限校验。

一、运行时位掩码的类型缺口
位掩码的本质是把每个权限映射到一个独立的二进制位。比如读取是 1,写入是 2,删除是 4,组合权限时直接做按位或,判断权限时做按位与。下面的代码是最常见的实现方式:
const Permissions = {
read: 1,
write: 2,
delete: 4,
} as const;
const userPerm = Permissions.read | Permissions.write;
function canDelete(perm: number) {
return (perm & Permissions.delete) === Permissions.delete;
}
canDelete(userPerm);
这段代码非常直观,但问题在于 userPerm 的运行时值虽然是 3,TypeScript 推导出的类型却是 number。as const 确实让 Permissions.read 获得了字面量类型 1,让 Permissions.write 获得了字面量类型 2,可是按位或运算符会返回 number,而不是 1 和 2 组合后的某个新字面量。于是 canDelete(userPerm) 在编译期完全合法,实际执行却会因为 3 & 4 不等于 4 而返回 false。
类似的情况还会出现在权限合并、权限叠加、角色继承等场景。只要类型系统把一切都当成 number,任何权限判断函数都可以接收不满足条件的掩码,所有校验压力都集中到运行时。对于小型项目或许还能接受,但权限逻辑一旦复杂起来,编译器本可以提前发现的越权组合就会被遗漏。要解决这个问题,不能继续在 number 上做文章,因为 TypeScript 没有提供类型级别的十进制位运算。更可行的办法是换一种表示,例如用定长二进制字符串把每一位置于类型系统可拆解的结构中。
二、用类型系统表示二进制位与位运算
TypeScript 类型层面没有数字加法,也没有原生的按位与操作符,但模板字面量类型允许我们拆开字符串的首字符和剩余部分。这个能力恰好适合递归处理二进制字符串。我们可以先用 Bit 表示单个二进制位,再为两个位定义 AndBit、OrBit、XorBit。这些类型虽然只是条件类型,但它们的计算发生在编译期,和运行时逻辑完全分离。
type Bit = '0' | '1'; type AndBit<A extends Bit, B extends Bit> = A extends '1' ? (B extends '1' ? '1' : '0') : '0'; type OrBit<A extends Bit, B extends Bit> = A extends '1' ? '1' : B; type XorBit<A extends Bit, B extends Bit> = A extends B ? '0' : '1';
单个位的逻辑只是第一步。对于完整二进制串,核心思路是通过 infer 把字符串拆成头部字符和剩余部分,对头部位做运算,然后把结果与剩余部分的递归结果拼回模板字面量。下面是对两个等长二进制串做按位与和按位或的类型实现:
type AndBinary<A extends string, B extends string> =
A extends `${infer AH}${infer AR}`
? B extends `${infer BH}${infer BR}`
? AH extends Bit
? BH extends Bit
? `${AndBit<AH, BH>}${AndBinary<AR, BR>}`
: never
: never
: never
: '';
type OrBinary<A extends string, B extends string> =
A extends `${infer AH}${infer AR}`
? B extends `${infer BH}${infer BR}`
? AH extends Bit
? BH extends Bit
? `${OrBit<AH, BH>}${OrBinary<AR, BR>}`
: never
: never
: never
: '';
以 AndBinary<'011', '100'> 为例,第一次递归取到 '0' 和 '1',AndBit 计算为 '0';剩余部分继续处理 '11' 与 '00',最终拼接成 '000'。整个推导过程不会生成任何 JavaScript 运行时代码,只存在于类型检查阶段。这种做法的优势是可组合、可追踪,但也要求字符串长度固定,否则递归会在长度不齐时返回 never。对于权限系统来说,权限位通常是预先定义且数量有限的,固定长度并不算限制。
三、从权限名称映射到位掩码并实现编译期校验
有了类型级位运算,下一步是把业务中的权限名称映射到二进制掩码。我们定义一个 PermissionName 联合类型和一张索引签名类型 MaskMap,通过索引访问类型 GetMask 把权限名转换为三位二进制字符串。之后,Combine 类型可以组合两个权限,HasPermission 类型可以判断组合掩码是否包含某个权限。
type PermissionName = 'read' | 'write' | 'delete';
type MaskMap = {
read: '001';
write: '010';
delete: '100';
};
type GetMask<P extends PermissionName> = MaskMap[P];
type Combine<A extends PermissionName, B extends PermissionName> =
OrBinary<GetMask<A>, GetMask<B>>;
type HasPermission<Mask extends string, P extends PermissionName> =
AndBinary<Mask, GetMask<P>> extends '000' ? false : true;
现在组合权限不再是 number,而是一个具体的二进制字符串字面量。比如 Combine<'read', 'write'> 会得到 '011'。如果想要在编译期禁止没有删除权限的掩码通过,可以定义 Assert 类型,要求传入类型必须是 true。当条件为 false 时,TypeScript 会直接给出类型错误。
type Assert<T extends true> = T; type EditorMask = Combine<'read', 'write'>; type CanRead = Assert<HasPermission<EditorMask, 'read'>>; type CanDelete = Assert<HasPermission<EditorMask, 'delete'>>;
上面的 CanRead 能通过编译,因为 '011' 与 '001' 按位与结果不是 '000',因此 HasPermission 返回 true。而 CanDelete 会触发错误:HasPermission<EditorMask, 'delete'> 计算出 false,Assert 约束中的 T extends true 无法满足。这样,越权判断就从运行时提前到了编译期。团队在重构权限时,只要修改 MaskMap 或权限名称,所有不匹配的调用都会在 IDE 中立刻变红。
四、用友好错误提示降低类型级编程的阅读门槛
直接使用 Assert<HasPermission<...>> 的报错信息往往是 Type 'false' does not satisfy constraint 'true'。这类信息虽然能指出问题,但缺少业务上下文,不熟悉类型级编程的开发者可能不知道哪里越权。为了让错误更易读,可以构造一个返回模板字面量类型的检查类型。它在权限不足时返回一段中文描述,把当前掩码和缺少的权限都放进错误信息里。
type PermissionCheck<Mask extends string, P extends PermissionName> =
HasPermission<Mask, P> extends true
? Mask
: `错误:${Mask} 没有 ${P} 权限`;
type EditorCheck = PermissionCheck<EditorMask, 'delete'>;
当鼠标悬停在 EditorCheck 上时,编辑器可能显示 错误:011 没有 delete 权限。这样就把类型错误变成了一种微型的业务文档。类似地,还可以为多个权限提供联合检查,或者用条件类型递归校验权限列表。需要注意的是,友好错误信息只存在于类型层,不会进入最终产物。类型级编程的另一个好处是这些描述不会增加任何运行时体积。
实际项目中,类型级权限校验可以和运行时实现配对使用。运行时仍然用原生位运算处理真实数据,类型层则负责约束函数签名和数据结构。例如函数声明为 hasPermission<M extends string, P extends PermissionName>(mask: M, permission: P): HasPermission<M, P>,返回类型会跟随传入参数变化。调用方在真值分支里可以获得布尔字面量类型,进一步缩小类型范围。这种双轨方式既保留了位运算的性能,又避免了纯运行时方案的类型缺口。
五、适用边界与工程落地建议
类型级位运算并不是银弹。首先,它依赖递归条件类型,TypeScript 对递归深度有默认限制。对于只包含几位或十几位权限的系统,这个限制完全够用;但若权限数量很大或需要处理任意长度掩码,类型计算的复杂度和编译时间会明显上升。其次,团队需要理解模板字面量类型、infer 和条件类型的组合方式,否则遇到复杂类型推导时容易卡住。类型级代码的可读性通常不如普通业务类型,必须有注释和命名规范。
更现实的做法是限制权限位数量,并保持二进制字符串定长。比如 5 位权限可以预先定义到 MaskMap 中,所有组合都由类型系统推导。运行时数据可以存储为数字或字符串,只要进入类型约束时转换为二进制字符串字面量即可。对于动态权限、超过几十个权限位、或者权限由后端元数据驱动的场景,更推荐用运行时校验加单元测试,而不是把全部逻辑塞进类型层。
总结来说,把位运算提升到 TypeScript 类型层,核心价值不是替代运行时判断,而是让编译器成为权限规则的一部分。通过 Bit、AndBinary、OrBinary、HasPermission 等类型,我们可以在编译期拦截越权组合,减少运行时错误。对权限系统这种逻辑固定、影响面广泛的模块,这种投入通常能带来较高的长期收益。
TypeScript类型级编程权限系统位运算修改时间:2026-09-25 15:41:46