一、为什么会出现TS和JS混用的场景

在实际项目开发中,很多人遇到过这样一种情况:项目里既有TypeScript写的文件,也有JavaScript写的文件。这在新项目里不常见,但在老项目迁移、第三方库集成、或者团队里有人还没完全转过来的时候,特别常见。

TypeScript本身就是为了给JavaScript加上类型检查而生成的。它的目标是让代码在编译阶段就能发现很多运行时才会出的问题。但当你的项目里同时存在这两种语言的文件时,类型信息就出现了一个断点——TypeScript编译器和JavaScript运行时之间的边界变得模糊了。

简单来说,TypeScript文件里有完整的类型信息,JavaScript文件里只有运行时才能知道的数据结构。当这两者交叉调用时,如果不做好类型边界处理,轻则出现编译警告,重则线上出bug。

这篇文章要讲的就是:当你的项目里同时有TypeScript和JavaScript代码时,怎么做好这两者之间的类型边界管理,让项目能顺利运行,减少出问题的概率。

1.1 典型使用场景

下面列举几个最常见的TS与JS混用的场景:

场景一:老项目渐进式迁移。 有些公司的项目一开始就是用JavaScript写的,现在要逐步迁移到TypeScript。不可能一蹴而就,通常会先给一些核心模块加上类型,其他模块暂时保留JavaScript。在这个过程中,TS和JS就会大量混用。

场景二:调用没有类型声明的第三方库。 有些老版本的前端库或者Node.js模块根本没有提供类型定义文件,你只能直接import它的JavaScript源码。这时候你写的TypeScript代码就要去调用没有类型信息的JS模块。

场景三:运行时动态代码。 有些代码在运行时才能确定结构,比如从接口返回的数据、从配置文件读取的内容。这些运行时数据在TypeScript里通常标记为any,本质上和JavaScript没有类型区别。

场景四:插件系统和扩展机制。 比如Webpack插件、Vue插件这些,主框架可能是TypeScript写的,但插件可能是JavaScript写的。主框架要调用插件,插件也要调用主框架暴露的接口,中间的类型边界怎么划就很重要了。

二、类型边界的定义与识别

在动手处理之前,我们得先搞清楚什么是"类型边界"。

类型边界指的是:一段TypeScript代码和一段JavaScript代码互相交互的交界点。具体来说,就是TS代码调用JS模块的地方,或者JS代码被TS代码import进来的地方。

在这个边界上,TypeScript编译器无法保证传入和传出的数据一定符合预期。因为JavaScript那边没有类型约束,任何东西都可以传过来——数字、字符串、对象、undefined、null,甚至是个函数。

2.1 边界类型识别的三种方式

第一种方式是通过文件后缀识别。TypeScript编译器默认会把.js文件当作无类型模块来处理,把.ts、.tsx文件当作有类型模块来处理。

第二种方式是通过tsconfig.json的allowJs配置项。当你把allowJs设为true时,编译器会允许你import JavaScript文件,但此时这些JS文件里的代码不会被类型检查,也不会产生类型声明。

第三种方式是通过声明文件。你可以写一个.d.ts文件,手动为某个JavaScript模块声明它的类型结构。编译器会根据这个声明来判断边界处的类型是否匹配。

2.2 一个实际的边界识别示例

假设我们有一个项目,结构如下:一个TypeScript文件要调用一个JavaScript模块。

技术栈:TypeScript 5.x + JavaScript ES6+ + Node.js

// 文件:src/utils.ts
// TypeScript文件:提供工具函数

/**
 * 计算两个数的和
 * 这个函数有完整的类型信息
 */
export function add(a: number, b: number): number {
  return a + b;
}

/**
 * 把数据格式化为字符串
 * @param data 输入数据
 */
export function format(data: string): string {
  return `[${data}]`;
}
// 文件:src/legacy.js
// JavaScript文件:历史遗留代码,没有类型信息

/**
 * 读取配置文件
 * 返回值结构不确定,可能是对象也可能是字符串
 */
function readConfig(filePath) {
  // 模拟读取文件,实际可能返回各种结构
  if (filePath === 'local') {
    return { host: 'localhost', port: 3000 };
  }
  return 'config string';
}

/**
 * 处理用户数据
 * @param {any} user - 用户对象,结构不确定
 */
function processUser(user) {
  console.log('Processing user:', user.name);
  return { processed: true };
}

module.exports = { readConfig, processUser };
// 文件:src/main.ts
// 主入口:TypeScript代码调用JavaScript模块

// 这里直接import JavaScript模块,编译器不知道返回什么
import { readConfig, processUser } from './legacy.js';

// readConfig返回值未知,可能是对象也可能是字符串
const config = readConfig('local');
// 下面这行代码在运行时如果config是字符串就会报错
// 因为字符串没有port属性
console.log(config.port); // 运行时报错:undefined

// processUser接收any类型,编译器不检查传入参数是否合理
const result = processUser({ name: '张三' });
console.log(result.processed);

上面这个例子展示了没有做边界处理时的情况。TypeScript编译器不会报错,因为它对import进来的JS模块没有类型约束。但在运行到config.port时,如果config实际是字符串,程序就崩了。

三、从JavaScript到TypeScript的安全过渡策略

当你的TypeScript代码要调用JavaScript模块时,有几种策略可以选择。

3.1 策略一:声明文件(Declaration Files)

最稳妥的做法是为JavaScript模块写一个.d.ts声明文件。这样TypeScript编译器在检查时就能根据声明文件来判断类型是否匹配。

技术栈:TypeScript 5.x + JavaScript ES6+ + Node.js

// 文件:src/legacy.d.ts
// 类型声明文件:为legacy.js模块提供类型信息

/**
 * 配置文件对象的类型定义
 */
export interface ConfigObject {
  host: string;
  port: number;
}

/**
 * 用户数据的类型定义
 */
export interface UserData {
  name: string;
  email?: string;
  age?: number;
}

/**
 * 处理结果的类型定义
 */
export interface ProcessResult {
  processed: boolean;
}

/**
 * 读取配置文件
 * @param filePath - 配置路径,支持 'local' 或其他字符串
 * @returns 配置对象或配置字符串
 */
export function readConfig(filePath: string): ConfigObject | string;

/**
 * 处理用户数据
 * @param user - 用户对象
 * @returns 处理结果
 */
export function processUser(user: UserData): ProcessResult;
// 文件:src/main.ts
// 使用声明文件后,编译器能检查类型边界

import { readConfig, processUser } from './legacy';

const config = readConfig('local');

// 编译器知道config是 ConfigObject | string
// 所以直接访问config.port会报错,提示需要先判断类型
// 这就帮我们提前发现了问题

if (typeof config === 'object') {
  // 这里编译器知道config是ConfigObject类型
  console.log(config.host);  // OK,string类型
  console.log(config.port);  // OK,number类型
} else {
  // 这里编译器知道config是string类型
  console.log(config);  // 打印字符串
}

const result = processUser({ name: '李四', email: 'test@test.com' });
// 编译器会检查传入对象是否符合UserData类型
console.log(result.processed); // OK,boolean类型

3.2 策略二:使用类型守卫做运行时检查

如果不想写声明文件,或者模块的返回值结构实在过于动态,可以用TypeScript的类型守卫在运行时做检查。

技术栈:TypeScript 5.x + JavaScript ES6+ + Node.js

// 文件:src/type-guards.ts
// 类型守卫文件:提供运行时类型检查函数

/**
 * 判断一个值是否是配置对象
 * @param value - 待检查的值
 * @returns 类型断言:value是ConfigObject
 */
export function isConfigObject(value: unknown): value is { host: string; port: number } {
  // 检查是否是对象
  if (typeof value !== 'object' || value === null) {
    return false;
  }
  // 检查是否有host和port属性
  const obj = value as Record<string, unknown>;
  return typeof obj.host === 'string' && typeof obj.port === 'number';
}

/**
 * 判断一个值是否是用户数据
 * @param value - 待检查的值
 * @returns 类型断言:value是UserData
 */
export function isUserData(value: unknown): value is { name: string; email?: string } {
  if (typeof value !== 'object' || value === null) {
    return false;
  }
  const obj = value as Record<string, unknown>;
  return typeof obj.name === 'string';
}

/**
 * 安全包装:把未知的返回值转换为安全的结构
 * @param value - 未知的输入值
 * @param fallback - 默认值
 * @returns 安全值
 */
export function safeValue<T>(value: unknown, fallback: T): T {
  // 如果值是undefined或null,返回默认值
  if (value === undefined || value === null) {
    return fallback;
  }
  // 如果类型不匹配,返回默认值
  if (typeof value !== typeof fallback) {
    return fallback;
  }
  // 类型匹配,直接返回
  return value as T;
}
// 文件:src/safe-main.ts
// 使用类型守卫来安全地调用JavaScript模块

import { readConfig, processUser } from './legacy';
import { isConfigObject, isUserData, safeValue } from './type-guards';

// 处理readConfig的不确定返回值
const config = readConfig('local');

if (isConfigObject(config)) {
  // 在这里编译器知道config是 { host: string; port: number }
  // 可以安全访问属性
  console.log('配置对象:', config.host, config.port);
} else {
  // 如果不是配置对象,就是字符串或其他类型
  console.log('非对象配置:', config);
}

// 安全处理用户数据
const rawUser = { name: '王五', email: 'w@w.com' };

if (isUserData(rawUser)) {
  // 在这里编译器知道rawUser是 { name: string; email?: string }
  const result = processUser(rawUser);
  console.log('处理完成:', result.processed);
} else {
  console.log('用户数据格式不正确');
}

// 使用安全包装函数
const name = safeValue<string>(config, '默认名称');
console.log('安全获取:', name);

3.3 策略三:封装适配层

当JavaScript模块被多处调用时,可以为它写一个TypeScript适配层,把所有类型不确定性都封装在适配层里,外部只调用适配层的类型安全接口。

技术栈:TypeScript 5.x + JavaScript ES6+ + Node.js

// 文件:src/adapter.ts
// 适配层:封装JavaScript模块的不确定性

import { readConfig, processUser } from './legacy';

/**
 * 配置数据的标准接口
 * 不管底层返回什么,适配层统一转换成这个结构
 */
export interface AppConfig {
  host: string;
  port: number;
  raw: unknown; // 原始数据,以备调试用
}

/**
 * 适配层:读取配置
 * 不管底层返回什么类型,都统一转换成AppConfig
 * @param filePath - 配置路径
 * @returns 标准化的配置对象
 */
export function loadConfig(filePath: string): AppConfig {
  const raw = readConfig(filePath);

  // 处理字符串返回值
  if (typeof raw === 'string') {
    // 尝试解析JSON字符串
    try {
      const parsed = JSON.parse(raw);
      return {
        host: parsed.host || 'localhost',
        port: parsed.port || 8080,
        raw: parsed
      };
    } catch {
      // 解析失败,返回默认值
      return { host: 'localhost', port: 8080, raw: raw };
    }
  }

  // 处理对象返回值
  return {
    host: raw.host || 'localhost',
    port: raw.port || 8080,
    raw: raw
  };
}

/**
 * 适配层:处理用户
 * 适配层负责校验输入,转换输出
 * @param name - 用户名称
 * @param email - 用户邮箱(可选)
 * @returns 处理结果
 */
export function handleUser(name: string, email?: string): { success: boolean; message: string } {
  // 构造用户对象
  const user = { name, email };

  // 调用底层JavaScript函数
  const result = processUser(user);

  // 检查返回结果
  if (result && typeof result === 'object' && result.processed === true) {
    return { success: true, message: '用户处理成功' };
  }

  return { success: false, message: '用户处理失败' };
}
// 文件:src/biz-logic.ts
// 业务逻辑层:只调用适配层,不直接碰JavaScript模块

import { loadConfig, handleUser } from './adapter';

export function initialize() {
  // 适配层保证了返回的一定是AppConfig类型
  // 业务代码可以安心使用
  const config = loadConfig('local');
  console.log(`服务地址: ${config.host}:${config.port}`);
}

export function registerUser(name: string, email?: string) {
  // 适配层处理了所有底层的不确定性
  // 业务代码只需要关心success和message
  const result = handleUser(name, email);
  console.log(`注册结果: ${result.success}, ${result.message}`);
}

四、从TypeScript到JavaScript的防御性处理

上一节讲的是TS调用JS的情况。反过来,当JavaScript代码要调用TypeScript导出的函数时,也需要做处理。因为JavaScript不知道那些TypeScript接口的类型要求。

4.1 JavaScript调用TypeScript模块时的保护

技术栈:TypeScript 5.x + JavaScript ES6+ + Node.js

// 文件:src/service.ts
// TypeScript服务模块:暴露给JavaScript调用的接口

/**
 * 用户服务的参数类型
 */
export interface UserServiceParams {
  name: string;
  age: number;
  active: boolean;
}

/**
 * 用户服务返回类型
 */
export interface UserServiceResult {
  success: boolean;
  userId: string;
  message: string;
}

/**
 * 创建用户服务
 * 这个函数会被JavaScript代码调用
 * 所以需要做防御性参数校验
 */
export function createUser(params: UserServiceParams): UserServiceResult {
  // 参数校验:确保传进来的数据结构符合预期
  // 因为JavaScript调用方可能传入任何东西

  if (typeof params !== 'object' || params === null) {
    return {
      success: false,
      userId: '',
      message: '参数必须是对象'
    };
  }

  if (typeof params.name !== 'string' || params.name.length === 0) {
    return {
      success: false,
      userId: '',
      message: '用户名不能为空'
    };
  }

  if (typeof params.age !== 'number' || params.age < 0) {
    return {
      success: false,
      userId: '',
      message: '年龄必须是正数'
    };
  }

  if (typeof params.active !== 'boolean') {
    return {
      success: false,
      userId: '',
      message: '活跃状态必须是布尔值'
    };
  }

  // 所有校验通过,执行业务逻辑
  return {
    success: true,
    userId: `user_${Date.now()}`,
    message: `用户 ${params.name} 创建成功`
  };
}
// 文件:src/legacy-caller.js
// JavaScript代码调用TypeScript模块

import { createUser } from './service.js';

/**
 * JavaScript调用TypeScript服务
 * 这里需要自己做参数检查,因为JS编译器不检查类型
 */
function registerUser() {
  // 构造参数
  // JavaScript代码需要确保传入的参数符合TypeScript接口的要求
  const params = {
    name: '赵六',
    age: 25,
    active: true
  };

  // 调用TypeScript函数
  const result = createUser(params);

  // 检查结果
  if (result.success) {
    console.log('创建成功, ID:', result.userId);
  } else {
    console.log('创建失败:', result.message);
  }
}

/**
 * 模拟错误调用
 */
function registerUserWithBadData() {
  // 这里故意传入错误的参数
  // TypeScript编译器会警告,但JavaScript代码不会
  // 所以服务端的防御性校验非常重要
  const badParams = {
    name: '',       // 错误:空字符串
    age: '二十五',  // 错误:字符串而不是数字
    active: 'yes'   // 错误:字符串而不是布尔值
  };

  const result = createUser(badParams);
  console.log('错误调用结果:', result.message);
}

registerUser();
registerUserWithBadData();

4.2 使用Zod做运行时校验

如果不想自己写一堆if判断,可以用运行时校验库比如Zod来做。Zod是一个TypeScript的运行时校验库,它能在运行时检查数据是否符合指定的schema。

技术栈:TypeScript 5.x + JavaScript ES6+ + Node.js + Zod

// 文件:src/validated-service.ts
// 使用Zod做运行时校验的服务

import { z } from 'zod';

/**
 * 定义用户参数的Zod schema
 * 这个schema同时充当类型定义和运行时校验
 */
const CreateUserParamsSchema = z.object({
  name: z.string().min(1, '用户名不能为空'),
  age: z.number().int().positive('年龄必须是正整数'),
  active: z.boolean()
});

/**
 * 从Zod schema推导TypeScript类型
 * 这样类型定义和校验逻辑保持一致
 */
type CreateUserParams = z.infer<typeof CreateUserParamsSchema>;

/**
 * 带运行时校验的创建用户函数
 */
export function createUserValidated(input: unknown): { success: boolean; userId: string; message: string } {
  // 使用Zod在运行时校验输入数据
  const parsed = CreateUserParamsSchema.safeParse(input);

  if (!parsed.success) {
    // 校验失败,返回错误信息
    const errorMessages = parsed.error.errors.map(err => {
      return `${err.path.join('.')}: ${err.message}`;
    });
    return {
      success: false,
      userId: '',
      message: `参数校验失败: ${errorMessages.join('; ')}`
    };
  }

  // 校验通过,parsed.data的类型自动推导为CreateUserParams
  // 可以放心使用
  const data = parsed.data;
  return {
    success: true,
    userId: `user_${Date.now()}`,
    message: `用户 ${data.name} 创建成功`
  };
}
// 文件:src/legacy-caller-zod.js
// JavaScript调用带Zod校验的服务

import { createUserValidated } from './validated-service.js';

// 正确调用
const result1 = createUserValidated({
  name: '孙七',
  age: 30,
  active: true
});
console.log('正确调用:', result1.message);

// 错误调用:参数类型不对
const result2 = createUserValidated({
  name: '',
  age: -1,
  active: 'true'
});
console.log('错误调用:', result2.message);
// 输出类似: 参数校验失败: name: 用户名不能为空; age: 年龄必须是正整数

// 错误调用:缺少字段
const result3 = createUserValidated({
  name: '周八'
  // 缺少age和active
});
console.log('缺少字段调用:', result3.message);

五、接口契约与类型声明文件的使用

在TS和JS混用的项目中,明确定义"接口契约"是最关键的策略之一。接口契约就是双方对数据结构的约定——什么字段、什么类型、什么范围。

5.1 编写共享接口文件

最佳实践是抽出一个独立的接口文件,TypeScript和JavaScript双方都引用这个文件来对齐数据结构。

技术栈:TypeScript 5.x + JavaScript ES6+ + Node.js

// 文件:src/contracts/user.contract.ts
// 接口契约文件:定义TypeScript和JavaScript共享的数据结构
// 这个文件只包含类型定义,不包含运行时代码

/**
 * 用户基本信息
 */
export interface UserBasic {
  id: string;
  name: string;
  email: string;
  createdAt: string; // ISO 8601 格式的时间字符串
}

/**
 * 用户完整信息
 */
export interface UserFull extends UserBasic {
  phone?: string;
  address?: string;
  roles: string[];
  avatar?: string;
}

/**
 * 用户列表的响应结构
 */
export interface UserListResponse {
  users: UserFull[];
  total: number;
  page: number;
  pageSize: number;
}

/**
 * API错误响应
 */
export interface ApiErrorResponse {
  code: string;
  message: string;
  detail?: Record<string, unknown>;
}

/**
 * 统一API响应格式
 */
export type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: ApiErrorResponse };
// 文件:src/api-provider.ts
// TypeScript模块:实现API接口

import { UserFull, UserListResponse, ApiResponse } from './contracts/user.contract';

/**
 * 获取用户列表
 * 返回类型严格符合契约定义
 */
export function getUserList(page: number, pageSize: number): ApiResponse<UserListResponse> {
  // 模拟数据
  const users: UserFull[] = [
    {
      id: 'u001',
      name: '测试用户A',
      email: 'a@test.com',
      createdAt: '2024-01-01T00:00:00.000Z',
      phone: '13800138001',
      roles: ['admin', 'user']
    },
    {
      id: 'u002',
      name: '测试用户B',
      email: 'b@test.com',
      createdAt: '2024-02-01T00:00:00.000Z',
      roles: ['user']
    }
  ];

  return {
    success: true,
    data: {
      users: users,
      total: 2,
      page: page,
      pageSize: pageSize
    }
  };
}
// 文件:src/api-consumer.js
// JavaScript模块:消费API接口
// 虽然JS没有类型检查,但开发者通过契约文件知道返回结构

import { getUserList } from './api-provider.js';

/**
 * JavaScript调用API
 * 开发者根据contracts/user.contract.ts知道返回结构
 * 所以可以安全地访问data.users等字段
 */
function displayUserList() {
  const response = getUserList(1, 10);

  // 根据契约,response.success是boolean
  if (!response.success) {
    console.log('错误:', response.error.message);
    return;
  }

  // 根据契约,response.data是UserListResponse
  // data.users是UserFull数组
  // data.total是number
  console.log('总用户数:', response.data.total);

  // 遍历用户列表
  response.data.users.forEach(function (user) {
    // 根据契约,user.name是string
    // user.roles是string数组
    console.log(user.name, user.roles.join(', '));
  });
}

displayUserList();

5.2 使用JSDoc为JavaScript文件添加类型注释

对于保留为JavaScript的文件,可以用JSDoc注释来添加类型信息。这样在编辑器和TypeScript编译器中都能看到类型提示。

技术栈:TypeScript 5.x + JavaScript ES6+ + Node.js

// 文件:src/typed-legacy.js
// 使用JSDoc注释的JavaScript文件
// 虽然文件后缀是.js,但编辑器会识别类型注释

/**
 * @typedef {Object} ProductInfo
 * @property {string} id - 商品ID
 * @property {string} name - 商品名称
 * @property {number} price - 商品价格
 * @property {string[]} categories - 商品分类
 * @property {boolean} inStock - 是否有库存
 */

/**
 * 获取商品信息
 * @param {string} productId - 商品ID
 * @returns {ProductInfo | null} - 商品信息,找不到返回null
 */
function getProductInfo(productId) {
  if (typeof productId !== 'string' || productId.length === 0) {
    return null;
  }

  // 模拟数据
  const products = {
    'p001': {
      id: 'p001',
      name: '测试商品A',
      price: 99.99,
      categories: ['电子', '数码'],
      inStock: true
    }
  };

  return products[productId] || null;
}

/**
 * 格式化商品价格
 * @param {ProductInfo} product - 商品信息
 * @returns {string} - 格式化后的价格字符串
 */
function formatPrice(product) {
  // 编辑器知道product.price是number类型
  // 所以会给出类型提示和自动补全
  return '¥' + product.price.toFixed(2);
}

/**
 * 显示商品详情
 * @param {string} productId - 商品ID
 */
function showProduct(productId) {
  /** @type {ProductInfo | null} */
  const product = getProductInfo(productId);

  if (product === null) {
    console.log('商品未找到');
    return;
  }

  // 编辑器知道product有price、name等属性
  // 可以给出自动补全和类型检查
  console.log(product.name);
  console.log(formatPrice(product));
  console.log('库存状态:', product.inStock ? '有货' : '缺货');
  console.log('分类:', product.categories.join(', '));
}

module.exports = { getProductInfo, formatPrice, showProduct };
// 文件:src/use-typed-legacy.ts
// TypeScript调用带JSDoc的JavaScript文件

import { getProductInfo, formatPrice, showProduct } from './typed-legacy.js';

/**
 * TypeScript调用带类型注释的JavaScript函数
 * 编辑器能识别JSDoc类型,给出类型检查
 */
export function displayProductDetail(id: string): void {
  // getProductInfo的返回类型是 ProductInfo | null
  // TypeScript编译器会根据JSDoc做类型检查
  const product = getProductInfo(id);

  if (product === null) {
    console.log('未找到');
    return;
  }

  // 编辑器知道product.price是number
  // 所以.toFixed(2)是可用的
  console.log(formatPrice(product));
  showProduct(id);
}

六、第三方库与动态代码的边界处理

除了项目内部的TS/JS混用,调用没有类型声明的第三方库也是常见的边界场景。

6.1 第三方库的类型处理

技术栈:TypeScript 5.x + JavaScript ES6+ + Node.js

// 文件:src/third-party-wrapper.ts
// 封装第三方库,处理类型不确定性

// 假设有一个没有类型声明的第三方库
// 在实际项目中,可能是从node_modules直接import

/**
 * 模拟一个没有类型声明的第三方库
 * 在真实项目中,这就是你直接import的JS模块
 */
declare module 'uncertain-lib' {
  // 这里我们手动声明类型,因为库本身没有提供
  export function fetchData(url: string, options?: Record<string, unknown>): Promise<unknown>;
  export function transformData(data: unknown, format: string): unknown;
}

/**
 * 安全的第三方库调用包装器
 */
export class SafeThirdPartyWrapper {
  /**
   * 安全地获取数据
   * @param url - 数据接口地址
   * @param options - 请求选项
   * @returns 解析后的数据,如果解析失败返回null
   */
  async fetchSecureData<T>(url: string, options?: Record<string, unknown>): Promise<T | null> {
    try {
      // 调用第三方库
      // 返回类型是unknown,因为库没有类型声明
      const rawData = await require('uncertain-lib').fetchData(url, options);

      // 对返回数据做运行时检查
      if (rawData === null || rawData === undefined) {
        return null;
      }

      if (typeof rawData !== 'object') {
        console.warn(`第三方库返回了非对象数据: ${typeof rawData}`);
        return null;
      }

      // 数据检查通过,返回类型化的结果
      // 注意:这里用as T做了类型断言
      // 调用方需要自己确保T是正确的类型
      return rawData as T;
    } catch (error) {
      console.error('第三方库调用失败:', error);
      return null;
    }
  }

  /**
   * 安全地转换数据
   * @param data - 原始数据
   * @param format - 目标格式
   * @returns 转换后的数据
   */
  transformSecurely<T>(data: unknown, format: string): T | null {
    try {
      const result = require('uncertain-lib').transformData(data, format);

      if (result === null || result === undefined) {
        return null;
      }

      return result as T;
    } catch (error) {
      console.error('数据转换失败:', error);
      return null;
    }
  }
}
// 文件:src/use-third-party.ts
// 使用第三方库包装器

import { SafeThirdPartyWrapper } from './third-party-wrapper';

/**
 * 定义期望的数据结构
 */
interface UserResponse {
  id: string;
  name: string;
  age: number;
}

async function fetchUsers() {
  const wrapper = new SafeThirdPartyWrapper();

  // 指定期望的返回类型
  // 包装器会在运行时做检查,但不保证运行时数据完全符合接口
  // 所以调用方最好再做一层验证
  const data = await wrapper.fetchSecureData<UserResponse[]>(
    'https://api.example.com/users',
    { headers: { 'Content-Type': 'application/json' } }
  );

  if (data === null) {
    console.log('获取用户数据失败');
    return;
  }

  // 额外验证:确保返回的数据结构合理
  // 因为包装器的类型断言不是100%可靠的
  const validUsers = data.filter((item) => {
    return (
      typeof item === 'object' &&
      item !== null &&
      typeof item.id === 'string' &&
      typeof item.name === 'string'
    );
  });

  console.log(`成功获取 ${validUsers.length} 个用户`);
  validUsers.forEach((user) => {
    console.log(user.name, user.age);
  });
}

fetchUsers();

6.2 处理动态导入的代码

有些场景需要动态导入模块,比如根据用户选择加载不同的功能模块。这时候类型边界更难处理。

技术栈:TypeScript 5.x + JavaScript ES6+ + Node.js

// 文件:src/dynamic-loader.ts
// 动态模块加载器

/**
 * 功能模块的接口定义
 * 不管加载的是TS还是JS模块,都必须符合这个接口
 */
export interface FeatureModule {
  /** 模块名称 */
  name: string;
  /** 模块版本号 */
  version: string;
  /** 初始化函数 */
  init: (config: Record<string, unknown>) => void;
  /** 销毁函数 */
  destroy: () => void;
}

/**
 * 验证一个模块是否符合FeatureModule接口
 */
export function isValidFeatureModule(mod: unknown): mod is FeatureModule {
  if (typeof mod !== 'object' || mod === null) {
    return false;
  }

  const m = mod as Record<string, unknown>;

  return (
    typeof m.name === 'string' &&
    typeof m.version === 'string' &&
    typeof m.init === 'function' &&
    typeof m.destroy === 'function'
  );
}

/**
 * 安全地加载功能模块
 * @param modulePath - 模块路径
 * @returns 验证后的功能模块,如果验证失败返回null
 */
export async function loadFeatureModule(modulePath: string): Promise<FeatureModule | null> {
  try {
    // 动态导入模块
    // 这里可能是.ts文件,也可能是.js文件
    // TypeScript的import()返回的是Promise<any>
    const mod = await import(modulePath);

    // 检查模块默认导出
    const candidate = mod.default ?? mod;

    // 使用类型守卫验证模块结构
    if (!isValidFeatureModule(candidate)) {
      console.warn(`模块 ${modulePath} 不符合FeatureModule接口要求`);
      return null;
    }

    return candidate;
  } catch (error) {
    console.error(`加载模块 ${modulePath} 失败:`, error);
    return null;
  }
}
// 文件:src/modules/user-module.js
// JavaScript实现的功能模块

export default {
  name: '用户模块',
  version: '1.0.0',
  init: function (config) {
    console.log('用户模块初始化,配置:', config);
  },
  destroy: function () {
    console.log('用户模块销毁');
  }
};
// 文件:src/modules/order-module.js
// JavaScript实现的功能模块

export default {
  name: '订单模块',
  version: '2.1.0',
  init: function (config) {
    console.log('订单模块初始化,配置:', config);
  },
  destroy: function () {
    console.log('订单模块销毁');
  }
};
// 文件:src/modules/broken-module.js
// 不符合接口约定的模块(模拟错误场景)

export default {
  name: '问题模块',
  // 缺少version字段
  // 缺少destroy函数
  init: function (config) {
    console.log('初始化');
  }
};
// 文件:src/main-dynamic.ts
// 动态加载功能模块的入口

import { loadFeatureModule, isValidFeatureModule } from './dynamic-loader';

async function main() {
  const modulePaths = [
    './modules/user-module.js',
    './modules/order-module.js',
    './modules/broken-module.js'
  ];

  for (const path of modulePaths) {
    const mod = await loadFeatureModule(path);

    if (mod === null) {
      console.log(`模块 ${path} 加载失败或不符合接口约定`);
      continue;
    }

    // mod的类型是FeatureModule
    // 可以放心调用init和destroy
    console.log(`加载成功: ${mod.name} v${mod.version}`);
    mod.init({ debug: true });
    // 后续使用时可以安全调用
    // mod.destroy();
  }
}

main().catch(console.error);

七、tsconfig配置的关键选项

在TS/JS混用的项目中,tsconfig.json的配置非常重要。下面介绍几个关键选项。

7.1 关键配置项详解

技术栈:TypeScript 5.x + JavaScript ES6+ + Node.js

{
  // tsconfig.json - 混合项目推荐配置

  "compilerOptions": {
    // 允许编译器包含JavaScript文件
    "allowJs": true,

    // 为JavaScript文件生成类型声明文件
    // 设为true后,编译器会为.js文件生成.d.ts
    "declaration": true,

    // 严格模式:开启所有严格类型检查
    "strict": true,

    // 当无法确定类型时不默认为any
    // 开启后,隐式any会报错
    "noImplicitAny": true,

    // 允许使用import导入JS模块
    // 这控制了JS文件的导入方式
    "esModuleInterop": true,

    // 将import当作CommonJS require来处理
    // 这样TS代码可以像JS一样使用require
    "module": "ESNext",

    // 模块解析策略
    "moduleResolution": "node",

    // 目标ES版本
    "target": "ES2020",

    // 输出目录
    "outDir": "./dist",

    // 根目录
    "rootDir": "./src",

    // 是否检查JS文件中的类型错误
    // 设为true时,即使JS文件也会报类型错误
    "checkJs": false,

    // 是否跳过第三方库的类型检查
    "skipLibCheck": true,

    // 源码映射
    "sourceMap": true
  },

  // 需要编译的文件
  "include": [
    "src/**/*.ts",
    "src/**/*.js"
  ],

  // 排除的文件
  "exclude": [
    "node_modules",
    "dist"
  ]
}

7.2 不同配置策略对比

{
  // 策略一:渐进式迁移配置
  // 适合老项目逐步迁移到TS的场景
  "project1": {
    "compilerOptions": {
      "allowJs": true,
      "checkJs": false,
      "strict": false,
      "noImplicitAny": false
    },
    "说明": "宽松模式,允许JS和TS共存,不强制类型检查"
  },

  // 策略二:严格迁移配置
  // 适合新项目或核心模块迁移
  "project2": {
    "compilerOptions": {
      "allowJs": true,
      "checkJs": true,
      "strict": true,
      "noImplicitAny": true
    },
    "说明": "严格模式,JS文件也会做类型检查,适合核心模块"
  },

  // 策略三:隔离配置
  // 适合TS和JS完全分离的场景
  "project3": {
    "compilerOptions": {
      "allowJs": false,
      "declaration": true
    },
    "说明": "不使用JS文件,通过声明文件(.d.ts)来桥接JS模块"
  }
}

7.3 项目引用(Project References)

对于大型项目,可以使用TypeScript的项目引用来管理不同的编译单元。

{
  // 根tsconfig.json
  "files": [],
  "references": [
    { "path": "./packages/shared" },
    { "path": "./packages/core" },
    { "path": "./packages/app" }
  ]
}
{
  // packages/shared/tsconfig.json
  // 共享类型定义包:只包含类型,不包含运行时代码
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationDir": "./dist",
    "outDir": "./dist",
    "strict": true
  },
  "include": ["src/**/*.ts"]
}
{
  // packages/core/tsconfig.json
  // 核心包:可以包含TS和JS混合代码
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "allowJs": true,
    "checkJs": false,
    "strict": true,
    "noImplicitAny": true,
    "outDir": "./dist"
  },
  "references": [
    { "path": "../shared" }
  ],
  "include": ["src/**/*.ts", "src/**/*.js"]
}

八、常见陷阱与注意事项

在实际开发中,TS和JS混用的项目有几个特别容易踩的坑。

8.1 any类型滥用问题

any是TypeScript里最危险的类型,它等于告诉编译器"我不管这个值的类型了,你随便处理"。在TS/JS混用项目中,any特别容易泛滥。

// 文件:src/pitfalls.ts
// 常见的类型边界陷阱示例

/**
 * 陷阱一:过度使用any
 */
function badExample(input: any): any {
  // 编译器完全放弃检查
  // 即使input是数字,下面的代码也不会报错
  return input.toUpperCase(); // 运行时才会发现问题
}

/**
 * 正确做法:使用unknown代替any
 */
function goodExample(input: unknown): unknown {
  // 编译器知道这是一个不确定的值
  // 必须先做类型检查才能使用
  if (typeof input === 'string') {
    // 在这里编译器知道input是string类型
    return input.toUpperCase(); // 类型安全
  }
  // 如果input不是string,不会执行到这里
  // 编译器也不会误以为其他属性可用
  return input;
}

/**
 * 陷阱二:类型断言的滥用
 */
const data = JSON.parse('{"name": "test"}');
// 直接断言可能有误
const unsafeData = data as { name: string; age: number };
console.log(unsafeData.age); // undefined,因为原始数据没有age字段

// 正确做法:先验证再断言
function parseAndValidate(json: string): { name: string; age: number } | null {
  try {
    const parsed = JSON.parse(json) as Record<string, unknown>;

    // 验证每个字段
    if (typeof parsed.name !== 'string') return null;
    if (typeof parsed.age !== 'number') return null;

    return parsed as { name: string; age: number };
  } catch {
    return null;
  }
}

8.2 可选链和空值合并的正确使用

在调用JS模块的返回值时,因为可能返回undefined或null,要用可选链和空值合并不是可选的。

// 文件:src/safe-access.ts
// 安全访问示例

/**
 * 模拟JS模块返回的不确定数据
 */
const response: unknown = {
  user: {
    profile: {
      name: '测试用户',
      avatar: null  // 注意这里avatar是null
    }
  }
};

/**
 * 陷阱:直接嵌套访问会崩溃
 * 如果user或profile是undefined,下面的代码会报错
 */
function badAccess(data: unknown) {
  // 如果data是undefined,这里就崩了
  // 如果data.user是undefined,也崩了
  // 如果data.user.profile是undefined,也崩了
  const name = data.user.profile.name;
  console.log(name);
}

/**
 * 正确:使用可选链
 */
function safeAccess(data: unknown) {
  // 逐步缩小类型范围
  if (typeof data === 'object' && data !== null) {
    const obj = data as Record<string, unknown>;

    // 检查user
    if (typeof obj.user === 'object' && obj.user !== null) {
      const user = obj.user as Record<string, unknown>;

      // 检查profile
      if (typeof user.profile === 'object' && user.profile !== null) {
        const profile = user.profile as Record<string, unknown>;

        // 检查name
        if (typeof profile.name === 'string') {
          console.log('用户名:', profile.name);
        }
      }
    }
  }
}

/**
 * 更简洁的写法:配合类型断言
 * 适合确定数据结构的情况下
 */
function conciseAccess(data: unknown) {
  // 如果确定数据结构,可以用as断言
  // 但断言前最好做一次基本的类型检查
  if (data && typeof data === 'object') {
    const typed = data as {
      user?: {
        profile?: {
          name?: string;
          avatar?: string | null;
        };
      };
    };

    // 使用可选链安全访问
    const name = typed.user?.profile?.name ?? '默认用户名';
    console.log('用户名:', name);
  }
}

8.3 循环引用的类型处理

当TS和JS模块互相引用时,会遇到循环引用问题。类型层面的循环引用可能导致编译失败或运行时行为异常。

// 文件:src/module-a.ts
// 模块A:TypeScript

import type { PluginInterface } from './module-b';
// 使用import type只导入类型,不产生运行时依赖
// 这样可以在编译阶段消除循环引用

/**
 * 模块A的导出接口
 */
export interface ModuleAInterface {
  name: string;
  execute: () => string;
}

/**
 * 创建一个插件实例
 * 类型来自module-b,但通过import type避免了运行时循环引用
 */
export function createPlugin(): PluginInterface {
  return {
    activate: () => {
      console.log('插件已激活');
    },
    deactivate: () => {
      console.log('插件已停用');
    },
    config: { enabled: true }
  };
}

/**
 * 模块A的主函数
 */
export function moduleAFunction(): string {
  return 'Module A executed';
}
// 文件:src/module-b.ts
// 模块B:TypeScript(也可以改成.js)

import type { ModuleAInterface } from './module-a';
// 同样使用import type

/**
 * 插件接口定义
 */
export interface PluginInterface {
  activate: () => void;
  deactivate: () => void;
  config: Record<string, unknown>;
}

/**
 * 模块B的导出接口
 */
export interface ModuleBInterface {
  name: string;
  process: (input: string) => string;
}

/**
 * 模块B的主函数
 * 如果需要在运行时调用模块A的函数,
 * 应该使用延迟加载(lazy import)来避免循环引用
 */
export async function moduleBFunction(): Promise<string> {
  // 延迟导入模块A,避免循环引用
  const moduleA = await import('./module-a');
  return moduleA.moduleAFunction();
}

九、应用场景深度分析

9.1 前端项目中的混用场景

在React或Vue项目里,TS/JS混用经常发生在以下场景:

老版本UI组件库没有提供类型声明,新项目用TypeScript写,但引用的组件库是JavaScript。这时候你需要为这些组件库写类型声明文件,或者在组件调用处用类型断言来处理。

动态组件加载的场景也很常见。比如根据路由配置动态加载组件,配置文件是JavaScript格式的,但组件本身是TypeScript写的。这时候需要确保配置文件里的组件路径能被正确解析。

9.2 Node.js服务中的混用场景

在Node.js后端项目中,混用通常发生在SDK封装场景。比如你写了一个TypeScript的SDK,但需要兼容用户可能用JavaScript写的配置文件。配置文件是JavaScript的JSON对象,SDK需要从中读取配置并做类型校验。

另一个常见场景是插件系统。核心框架是TypeScript写的,但允许用户用JavaScript写插件。框架需要定义一套稳定的接口,确保用户插件不管用什么语言写都能正确运行。

9.3 全栈项目中的混用场景

前端是TypeScript,后端是JavaScript的情况也不少见。这时候前端的类型定义需要和后端API的响应结构对齐。最佳做法是两边共享同一个类型定义文件,或者用API Schema工具来自动生成类型。

十、技术优缺点

10.1 声明文件(.d.ts)策略

优点:

  • 类型安全:编译器能检查TS调用JS时的类型匹配
  • 维护性好:类型定义集中在一个文件,修改时只需要改一处
  • 编辑器友好:代码补全和类型提示都能正常工作
  • 团队协作:新人可以通过声明文件快速了解模块的接口结构

缺点:

  • 需要手动维护:当JavaScript代码的接口变了,声明文件也要同步更新
  • 可能不准确:声明文件里写的是类型定义,但实际运行时数据可能不符合
  • 学习成本:团队成员需要理解.d.ts文件的语法和用法

10.2 运行时校验(Zod等)策略

优点:

  • 双重保障:编译时有类型检查,运行时也有校验
  • 自动推导:校验逻辑和类型定义来自同一个schema,不会脱节
  • 错误信息友好:校验失败时能给出具体是哪个字段出了问题
  • 适合外部数据:对接口返回值、配置文件等不可信数据特别有效

缺点:

  • 增加运行时开销:每次调用都要做校验,有性能损耗
  • 引入额外依赖:需要安装校验库
  • 学习成本:需要理解校验库的schema语法

10.3 适配层封装策略

优点:

  • 隔离性好:不确定性都被封装在适配层里,外部代码不受影响
  • 可测试:适配层是独立的模块,可以单独写测试
  • 演进方便:底层JavaScript模块变了,只需要改适配层
  • 接口稳定:适配层对外暴露的接口可以保持不变

缺点:

  • 增加代码量:适配层本身也是代码,需要维护
  • 多层调用:调用链变长,调试时可能多绕几层
  • 初始成本高:写适配层需要花时间分析底层模块的所有接口

十一、注意事项清单

在做TS/JS混用项目的类型边界处理时,需要特别注意以下几点:

不要信任JavaScript返回的数据。 不管JavaScript代码写了什么注释或者你预期它返回什么,都要做运行时校验。JavaScript是动态类型语言,任何类型的值都可能传过来。

any类型是最后的选项。 只有在完全无法确定类型、且无法做运行时校验的情况下,才考虑使用any。优先使用unknown,因为它至少要求你做类型检查才能使用。

类型断言要有节制。 as关键字虽然能解决类型问题,但它本质上是告诉编译器"相信我"。如果断言不对,运行时照样会崩。尽量在断言之前做实际的数据检查。

保持类型定义和实际代码的同步。 不管是声明文件还是接口定义,都要确保它们和实际的JavaScript代码保持一致。否则类型检查就失去了意义。

考虑性能影响。 运行时校验虽然安全,但有性能代价。对于高频调用的函数,可以只在开发环境开启校验,生产环境关闭。

团队约定要统一。 在团队内部建立TS/JS混用的编码规范,比如哪些文件必须用TypeScript、哪些可以用JavaScript、接口定义放在哪里等等。约定要文档化,让所有成员都遵守。

渐进式迁移,不要一步到位。 老项目迁移到TypeScript是一个渐进过程,不要试图一次性把所有JavaScript代码都转成TypeScript。可以先从新增代码和核心模块开始。

十二、文章总结

TypeScript和JavaScript混合开发时的类型边界处理,本质上是在类型安全开发和渐进式迁移之间找到平衡。没有一种策略能解决所有问题,需要根据具体场景选择合适的方案。

对于小规模的混用场景,比如只有一两个JavaScript模块被TypeScript调用,写一个声明文件(.d.ts)就足够了。这是成本最低、效果最直接的方式。

对于中规模的混用场景,比如项目中有很多JavaScript遗留代码需要逐步迁移,适配层封装策略更合适。它把不确定性隔离在一个模块里,业务代码不需要关心底层的类型混乱。

对于高安全要求的场景,比如处理用户输入、第三方API数据、配置文件等不可信数据,运行时校验是必须的。编译时类型检查不能替代运行时校验,这是TypeScript和JavaScript混合项目的铁律。

无论选择哪种策略,核心原则都是一样的:不信任边界的另一端。TypeScript这边要假设JavaScript那边可能传任何东西过来,JavaScript这边要假设TypeScript导出的接口可能有类型要求。做好防御性的类型检查和数据处理,项目才能稳。

最后一点建议:如果你的项目还处于早期阶段,尽量从一开始就用TypeScript写。等JavaScript代码积累多了再迁移,成本会高很多。但如果你已经有一个运行良好的JavaScript项目,也别纠结,用上面讲的方法做渐进式迁移就好。类型边界处理不是终点,而是通往全类型安全项目的桥梁。