TypeScript 深入:判别联合、条件类型与边界契约
从结构类型到业务约束
TypeScript用never穷尽检查验证判别联合没有遗漏分支;Result联合让成功值和失败信息各有明确访问条件。
前置为首批课程的strict、联合、unknown和模块;沿用TypeScript7.0.2与Node24,不另引运行库。结构类型按成员兼容,不按类名承诺业务合法;number无法单独保证非负、安全整数或金额上限。readonly限制通过该类型接口赋值,不冻结运行对象;需要运行不可变边界时另用复制或freeze。本项目用unknown解析外部事件,Result返回可预期业务失败,不用as绕过输入规则。
Event用type字面量区分credit/debit/reset。switch后default把剩余值赋给never;新增变体却未处理时编译失败。Result的ok是另一种判别联合,只有ok为true才能访问value;余额不足返回error,保留原输入对象。不要把所有错误都转换成空结果,调用者需要区分失败与有效余额0。账户余额/次数另检查安全整数范围,避免连续运行溢出;外部事件amount必须0–100000整数。
泛型、映射类型与条件类型
groupBy<T,K extends PropertyKey>在输入与输出之间保存元素和键的关系,不能把所有键提前拓宽为string。T不应被函数任意构造;调用者提供keyOf决定分组。返回ReadonlyMap是编译约束,JavaScript调用者仍可能调用set;桶数组真实freeze,但桶内对象仍是原始对象引用。若业务要求深不可变,应复制成员并重新定义所有权,不以只读类型冒充运行隔离。
NumericKeys<T>遍历keyof T,利用条件类型选出number属性,再索引为键联合;-?去掉可选标记,但可选属性的值仍可能含undefined,需要按业务决定是否接受。ValueOfResult只在成功分支推断value;若对整个Result<infer V,unknown>推断,失败分支可能推断unknown,使number|unknown退化,静态保证被悄悄丢失。项目的@ts-expect-error回归会在这类退化时产生TS2578,防止“编译通过”掩盖变弱的类型。
条件类型遇裸类型参数联合会分发,never通常代表无成员;想整体判断时可用元组包裹,但先确认期望语义。类型体操有编译成本,优先用清晰的联合和函数签名;递归条件类型不是运行时算法,不能解析真正未知的JSON。
完整项目及双层验证
在原examples/web-plus/typescript工程内新增以下文件,依赖/tsconfig/锁文件与完整首批项目共享;不是只能嵌入未知上下文的片段。src参与strict编译,fixtures独立执行预期失败编译,不混入正常构建。
src/advanced.ts
export type Result<T,E>={readonly ok:true;readonly value:T}|{readonly ok:false;readonly error:E};
export type ValueOfResult<T>=T extends {readonly ok:true;readonly value:infer V}?V:never;
export type NumericKeys<T>={[K in keyof T]-?:T[K] extends number?K:never}[keyof T];
export type Account=Readonly<{balance:number;entries:number}>;
export type Event={type:'credit';amount:number}|{type:'debit';amount:number}|{type:'reset'};
export function parseEvent(value:unknown):Event{
if(!value||typeof value!=='object'||Array.isArray(value)||!('type' in value))throw new Error('invalid event');
if(value.type==='reset')return {type:'reset'};
if((value.type==='credit'||value.type==='debit')&&'amount' in value&&typeof value.amount==='number'&&Number.isSafeInteger(value.amount)&&value.amount>=0&&value.amount<=100000)return {type:value.type,amount:value.amount};
throw new Error('invalid event');
}
export function applyEvent(account:Account,event:Event):Result<Account,string>{
try{event=parseEvent(event);}catch{return {ok:false,error:'invalid event'};}
if(!account||!Number.isSafeInteger(account.balance)||account.balance<0||!Number.isSafeInteger(account.entries)||account.entries<0)return {ok:false,error:'invalid account'};
if(event.type!=='reset'&&account.entries===Number.MAX_SAFE_INTEGER)return {ok:false,error:'entry overflow'};
switch(event.type){
case 'credit':if(!Number.isSafeInteger(account.balance+event.amount))return {ok:false,error:'balance overflow'};return {ok:true,value:{balance:account.balance+event.amount,entries:account.entries+1}};
case 'debit':return account.balance<event.amount?{ok:false,error:'insufficient balance'}:{ok:true,value:{balance:account.balance-event.amount,entries:account.entries+1}};
case 'reset':return {ok:true,value:{balance:0,entries:0}};
default:{const exhaustive:never=event;return exhaustive;}
}
}
export function groupBy<T,K extends PropertyKey>(items:readonly T[],keyOf:(item:T)=>K):ReadonlyMap<K,readonly T[]>{
const grouped=new Map<K,T[]>();
for(const item of items){const key=keyOf(item);const bucket=grouped.get(key);if(bucket)bucket.push(item);else grouped.set(key,[item]);}
return new Map([...grouped].map(([key,values])=>[key,Object.freeze(values)]));
}src/type-checks.ts
import type {Account,NumericKeys,Result,ValueOfResult} from './advanced.js';
const key:NumericKeys<Account>='balance';
const value:ValueOfResult<Result<number,string>>=1;
// @ts-expect-error 条件类型应只提取成功分支的number,不能退化成unknown
const bad:ValueOfResult<Result<number,string>>='wrong';
void [key,value,bad];advanced.test.mjs
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {parseEvent,applyEvent,groupBy} from './dist/advanced.js';
test('判别联合处理信用、余额不足与重置;失败不修改输入',()=>{
const initial=Object.freeze({balance:0,entries:0});
const added=applyEvent(initial,parseEvent({type:'credit',amount:20}));assert.equal(added.ok,true);assert.equal(added.value.balance,20);
const failed=applyEvent(added.value,parseEvent({type:'debit',amount:21}));assert.deepEqual(failed,{ok:false,error:'insufficient balance'});assert.equal(added.value.balance,20);
assert.deepEqual(applyEvent(added.value,{type:'reset'}),{ok:true,value:{balance:0,entries:0}});
for(const event of [null,[],{type:'missing'},{type:'credit',amount:'2'},{type:'debit',amount:-1}])assert.throws(()=>parseEvent(event));
for(const event of [{type:'credit',amount:-1},{type:'credit',amount:NaN},{type:'debit',amount:-2},{type:'debit',amount:NaN},{type:'debit',amount:1.5},{type:'debit',amount:100001}])assert.deepEqual(applyEvent(initial,event),{ok:false,error:'invalid event'});
assert.deepEqual(applyEvent({balance:0,entries:Number.MAX_SAFE_INTEGER},{type:'reset'}),{ok:true,value:{balance:0,entries:0}});
});
test('泛型分组保留键类型与不可变数组;输入不被改变',()=>{
const input=Object.freeze([{kind:'a',value:1},{kind:'a',value:2},{kind:'b',value:3}]);const groups=groupBy(input,x=>x.kind);
assert.deepEqual(groups.get('a'),input.slice(0,2));assert.ok(Object.isFrozen(groups.get('a')));assert.equal(input.length,3);
});fixtures/exhaustive.ts
type Event={type:'read'}|{type:'write'};
function handle(event:Event){switch(event.type){case 'read':return 1;default:{const exhaustive:never=event;return exhaustive;}}}
handle({type:'write'});
export {};package.json
{
"name": "m3-typescript-report",
"private": true,
"type": "module",
"scripts": {"build": "tsc -p tsconfig.json", "test": "npm run build && node --test *.test.mjs", "demo": "npm run build && node demo.mjs"},
"engines": {"node": ">=22.12.0"},
"devDependencies": {"typescript": "7.0.2"}
}tsconfig.json
{
"compilerOptions": {"target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022"], "strict": true, "noUncheckedIndexedAccess": true, "rootDir": "src", "outDir": "dist", "types": [], "noEmitOnError": true},
"include": ["src/**/*.ts"]
}进入examples/web-plus/typescript,执行npm ci,再npm test,预期四组运行测试通过,strict编译同时通过静态类型断言。接着执行npx --no-install tsc --strict --noEmit --ignoreConfig fixtures/exhaustive.ts,预期非零退出,TS2322指出write不能赋给never;这是指定负例成功,不是正常构建失败。负例末尾的export {}使文件成为模块,避免自定义Event与默认DOM声明的全局Event重名,干扰预期诊断。原fixtures/invalid.ts仍保留最基础string赋number反例。type-checks只编译,不作为业务入口运行。
编译、发布与诊断
NodeNext决定模块解析和输出语义,src内使用.js后缀引用最终运行文件;编辑器找到.ts不代表Node会自动补扩展名。target选择语法降级,lib描述可用声明,二者不注入polyfill。浏览器库和Node库的边界不能靠增加任意全局类型包抹平。声明文件是类型契约,发布时需要验证包exports、types、生成文件及真实消费者;本工程只交付本地库,不冒称已发布npm包。
TS2322先检查真实不兼容分支,TS2578检查预期失败是否已经消失,TS18048检查未定义值路径;不要统一加any、非空断言或skipLibCheck。变慢时可对代表性项目运行tsc --extendedDiagnostics,比较类型实例化和检查时间,输出受机器及缓存影响,不以固定毫秒作为通过标准。编译器版本升级另跑完整负例,避免新默认值改变项目语义。
练习与解答
- 增加freeze事件,说明哪些文件应该先编译失败。2. 将分组元素改成可变对象,指出readonly和freeze分别保护哪层。3. 为什么JSON.parse后的as Event不能代替parseEvent?
参考解法
新增联合成员后applyEvent的never赋值应失败,显式处理后再加正常/失败测试;只修改类型而删除穷尽检查不是实现。只读Map类型不阻止JS调用set,freeze桶数组不冻结对象成员,需明确复制深度。as不产生运行代码,无法排除坏type、负金额或非整数;parseEvent检查输入并构造只包含需要字段的新事件。
依据:官方条件类型、收窄与never、映射类型,中文基础组织对照TypeScript教程,关键结论以实际strict正反例核对;2026-10-17。
继续阅读
- 类型系统