TypeScript 与 JavaScript 混编工程的 5 条类型边界治理法则
标签:工程化, TypeScript, JavaScript, 最佳实践
引言
前端项目从 JavaScript 迁移到 TypeScript 几乎不存在"一步到位"——真实的工程场景永远是 .ts 与 .js 混编共存。然而混编带来了一个被严重低估的问题:类型边界。当 TypeScript 模块引用 JavaScript 模块时,类型信息在边界处断裂,编译器默认退化为 any,造成"你以为有类型安全,其实什么都没有"的虚假安全感。
本文将围绕混编工程中 5 条经过生产验证的类型边界治理法则展开,每条法则都附带正反例代码对比。
法则 1:JS 模块必须有显式类型声明文件
❌ 反例
// utils/legacy-helper.js(存量 JS 模块)
export function formatPrice(price, currency) {
return `${currency}${price.toFixed(2)}`;
}
// 消费方
import { formatPrice } from './utils/legacy-helper';
const result = formatPrice(99.9, '¥'); // ✅ 运行时正确,但 result 类型为 any
const broken = formatPrice('99.9', 123); // ❌ 编译期零报错,运行时 price.toFixed is not a function
默认情况下 TS 不会为 .js 文件生成类型。导入 legacy-helper.js 时,formatPrice 被推断为 (...args: any[]) => any,所有类型检查在边界处消失。
✅ 正例
// utils/legacy-helper.d.ts
export function formatPrice(price: number, currency: string): string;
declare module '*/legacy-helper' {
export function formatPrice(price: number, currency: string): string;
}
配置 tsconfig.json 确保声明文件生效:
{
"compilerOptions": {
"allowJs": true,
"declaration": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
治理效果:formatPrice 在消费侧获得精确类型签名,参数类型错误在编译期即可暴露。
法则 2:allowJs + checkJs 作为过渡期最低配置
❌ 反例:关闭 checkJs
{
"compilerOptions": {
"allowJs": true,
"checkJs": false // ← 默认值,JS 文件零检查
}
}
代价:存量 JS 文件中的低级错误(类型拼写、参数数量不匹配)完全绕过编译器。
✅ 正例:开启 checkJs + JSDoc 类型标注
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"strict": false // 可对 JS 降级 strict,防止存量代码报错爆炸
}
}
在 JS 文件中使用 JSDoc 渐进式添加类型:
// @ts-check
/**
* @param {number} price
* @param {string} currency
* @returns {string}
*/
export function formatPrice(price, currency) {
if (typeof price !== 'number') {
throw new TypeError('price must be number');
}
return `${currency}${price.toFixed(2)}`;
}
checkJs: true 让 TypeScript 编译器像对待 .ts 一样检查 .js 文件,JSDoc 注解承担类型角色。这是零迁移成本向 TypeScript 对齐的第一步。
法则 3:构建公共类型桥梁,消灭冗余的 @type/@ts-ignore
❌ 反例
// types/product.ts
export interface Product {
id: number;
name: string;
price: number;
}
// components/ProductCard.js - 消费方是 JS
// @ts-ignore - "Product 类型不可用于 JS 上下文" 的暴力压制
/** @type {import('../types/product').Product} */
const product = { id: 1, name: 'Widget', price: 9.99 };
@ts-ignore 是类型治理的"破窗效应"——出现一个就会裂变出十个。
✅ 正例:共享类型包
packages/
shared-types/
src/
product.ts # 纯类型定义,零运行时依赖
package.json # "types": "./src/index.ts"
// packages/shared-types/src/product.ts
export interface Product {
id: number;
name: string;
/** ISO 4217 currency code */
currency: string;
price: number;
}
// components/ProductCard.js
/** @type {import('@internal/shared-types').Product} */
const product = { /* ... */ };
抽取公共类型到独立包/目录,TS 和 JS 模块统一引用,避免类型重复定义和版本漂移。
法则 4:在边界处建立运行时类型校验门禁
类型声明文件和 JSDoc 只在编译期生效。当数据跨越网络边界(API 响应)或进程边界(Web Worker、iframe)进入 TypeScript 代码时,编译期类型承诺完全失效。
❌ 反例:信任编译期类型
interface ApiResponse {
data: { items: Product[] };
total: number;
}
async function fetchProducts(): Promise<ApiResponse> {
const res = await fetch('/api/products');
return res.json(); // ❌ 运行时可能是任何形状
}
// 使用
const { data, total } = await fetchProducts();
data.items.forEach(item => {
console.log(item.price.toFixed(2)); // 💥 如果 API 返回字符串 price,运行时爆炸
});
✅ 正例:边界处 Zod 校验
import { z } from 'zod';
const ProductSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number(),
});
const ApiResponseSchema = z.object({
data: z.object({
items: z.array(ProductSchema),
}),
total: z.number(),
});
// 从 schema 推导 TypeScript 类型,单一事实源
type ApiResponse = z.infer<typeof ApiResponseSchema>;
async function fetchProducts(): Promise<ApiResponse> {
const res = await fetch('/api/products');
const raw = await res.json();
return ApiResponseSchema.parse(raw); // ✅ 运行时校验 + 类型收窄
}
Zod / Yup / io-ts 等运行时校验库在边界处构建"最后防线",将不符合预期的数据在入口处拦截,同时自动推导出 TypeScript 类型——消除了手写类型声明与运行时校验之间的不一致。
法则 5:CI 中同时执行 TS 编译检查与 JS Lint
❌ 反例:CI 只跑 tsc --noEmit
# .github/workflows/ci.yml
- name: Type Check
run: npx tsc --noEmit
tsc --noEmit 在 allowJs: true + checkJs: false 的组合下几乎不检查 JS 文件。你的 CI "绿"了,但类型安全是假的。
✅ 正例:分层检查矩阵
- name: TypeScript 类型检查(严格模式)
run: npx tsc --noEmit --project tsconfig.strict.json
- name: JavaScript JSDoc 类型检查
run: npx tsc --noEmit --project tsconfig.checkjs.json
- name: ESLint 类型感知规则
run: npx eslint . --ext .ts,.js --max-warnings 0
// tsconfig.strict.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "strict": true },
"include": ["src/**/*.ts"]
}
// tsconfig.checkjs.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "checkJs": true, "strict": false },
"include": ["src/**/*.js"]
}
分层策略的好处:
- 新增
.ts文件受strict约束,不因存量.js降低标准; .js文件渐进式获得 JSDoc 类型检查,不阻塞迭代;- ESLint 的
@typescript-eslint规则可识别类型信息(如no-unnecessary-type-assertion),覆盖tsc盲区。
治理路线图总结
| 阶段 | 动作 | 成本 | 收益 |
|---|---|---|---|
| 立即执行 | 为所有被 TS 引用的 JS 模块添加 .d.ts | 低 | 消除隐式 any 传播 |
| 1 周内 | 开启 checkJs,为关键 JS 函数添加 JSDoc | 中 | 存量代码获得类型覆盖 |
| 2 周内 | 抽取公共类型到 shared-types 目录 | 中 | 消除类型重复定义 |
| 1 月内 | 在 API/边界层接入 Zod 运行时校验 | 高 | 构建期+运行时双重保障 |
| 持续 | CI 分层检查 + ESLint 类型感知规则 | 低 | 防止类型退化 |
类型边界治理的核心不是"全部迁移到 TypeScript",而是让边界可见、可检查、可自动化。每一条法则都在回答同一个问题:当类型信息跨越 .ts ↔ .js 边界时,谁来保证它没有丢失?
答案是:你自己建立的治理体系。
评论区
登录 后参与评论