跳到主要内容

TypeScript 项目接入

前面的几篇更偏语言能力,这一篇只聊一件事:如果你要把 TypeScript 真正接到一个普通项目里,通常应该怎么做。

这里不依赖当前仓库,默认你面对的是一个常见 Web 项目,比如 React、Vue、Vite、Next.js、Node 服务端,或者一个还在从 JavaScript 逐步迁移的老项目。

什么时候该正式接入 TypeScript

下面几种情况,通常就已经不是“可选项”了,而是值得正式接入:

  • 项目开始拆模块、拆组件、拆接口
  • 同一个对象会在多个页面、多个函数、多个接口间来回传递
  • 团队开始协作,大家需要一套统一的参数和返回值约束
  • 线上问题经常来自字段拼错、空值漏判、调用方和实现方理解不一致

如果只是几百行的临时脚本,或者一次性验证思路的 Demo,可以先不急着全量上 TypeScript。但只要项目有“会继续维护”的预期,尽量早接。

最小接入步骤

1. 安装依赖

pnpm add -D typescript

如果是 React 项目,通常还会补上:

pnpm add -D @types/react @types/react-dom

Node 项目一般还会补:

pnpm add -D @types/node

2. 初始化 tsconfig.json

npx tsc --init

初始化之后,不要把它当成“生成完就结束了”的文件。tsconfig.json 本质上是整个项目的类型系统入口,它决定了:

  • 你的代码按什么 JS 目标编译
  • DOM、Node、ES API 哪些类型可用
  • 严格模式开到什么程度
  • 路径别名怎么解析
  • 哪些文件会被 TypeScript 检查

3. 先把最关键的几项配起来

一个比较通用、适合前端项目起步的配置大致是这样:

{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "types", "vite.config.ts"],
"exclude": ["node_modules", "dist", "build"]
}

接入时最重要的几个配置项

strict

这是最值得尽早打开的一项。它不是一个小开关,而是一组更严格的类型检查规则。

如果是新项目,建议直接开。

如果是老项目迁移,实在压力大,可以先分阶段:

  1. 先让 .ts / .tsx 能跑起来
  2. 再逐步打开 strictNullChecksnoImplicitAny
  3. 最后再把整个 strict 收口

lib

这决定了你能使用哪些全局类型。

  • 浏览器项目常见:DOMDOM.Iterable
  • 通用现代 JS 能力:ES2020 或更高
  • Node 项目通常还会结合 @types/node

如果你在浏览器项目里误删了 DOM,很多像 windowdocumentHTMLElement 这样的类型会直接消失。

jsx

React 项目一般用:

{
"jsx": "react-jsx"
}

baseUrl / paths

这组配置主要解决导入路径太长的问题。

{
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}

这样就能把:

import Button from "../../../../components/Button";

改成:

import Button from "@/components/Button";

但要注意一件事:TypeScript 能识别路径别名,不代表你的打包器、测试工具、运行时也一定能识别。通常还要同步到:

  • Vite 的 resolve.alias
  • Webpack / Rspack 的 resolve.alias
  • Jest / Vitest 的 alias 配置
  • Node 运行时或 ts-node 的路径解析方案

普通项目里推荐的目录约定

一个常见、足够清晰的组织方式可以是:

src/
components/
pages/
hooks/
services/
utils/
types/
types/
env.d.ts
global.d.ts
tsconfig.json

建议这样分:

  • src/types/:项目业务类型,比如 UserArticleApiResponse<T>
  • types/*.d.ts:全局声明、环境变量声明、第三方模块补充声明

不要把所有类型都堆到一个 types.ts 里。类型和代码一样,也需要按职责分开。

真正落地时最常见的几种类型

1. API 响应类型

type ApiResponse<T> = {
code: number;
message: string;
data: T;
};

type User = {
id: string;
name: string;
email?: string;
};

async function getUser(): Promise<ApiResponse<User>> {
const res = await fetch("/api/user");
return res.json();
}

2. 组件 Props

type ButtonProps = {
variant?: "primary" | "secondary";
disabled?: boolean;
children: React.ReactNode;
};

export function Button({
variant = "primary",
disabled = false,
children,
}: ButtonProps) {
return <button disabled={disabled}>{children}</button>;
}

3. 配置对象

type NavItem = {
label: string;
href: string;
external?: boolean;
};

const navItems: NavItem[] = [
{ label: "首页", href: "/" },
{ label: "文档", href: "/docs" },
];

老项目迁移时的建议顺序

不要上来就想“一口气把整个 JavaScript 项目全部改成 TypeScript”。更稳的顺序通常是:

  1. 先加 tsconfig.json 和类型检查脚本
  2. 先从新文件开始写 .ts / .tsx
  3. 先迁移配置对象、工具函数、接口层
  4. 再迁移页面组件和复杂业务模块
  5. 最后处理历史遗留的 any、隐式 any 和空值问题

接入后最好补上的脚本

最起码建议加一个:

{
"scripts": {
"typecheck": "tsc --noEmit"
}
}

这样 TypeScript 就不只是编辑器里的提示,而是可以进入日常校验流程。

环境变量声明怎么补

这是很多项目接入 TypeScript 后第二批会遇到的问题:代码已经能跑了,但一写环境变量就开始报类型不明确。

不同工具链的写法会不一样,但原则基本一致:给“项目里会用到的环境变量”补一层声明。

Vite 项目

通常会建一个 env.d.ts

/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_APP_TITLE: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}

这样你在项目里写:

const baseUrl = import.meta.env.VITE_API_BASE_URL;

就不再只是一个模糊的字符串来源,而是有明确声明的项目配置。

Node / Next.js 一类项目

更常见的是给 process.env 补约束:

declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
NEXT_PUBLIC_API_BASE_URL?: string;
DATABASE_URL: string;
}
}

这里有两个实践建议:

  • 真正必须存在的变量可以声明成必填
  • 可能缺省的变量保留可选,再在运行时做校验

只靠类型声明还不够

类型声明解决的是“编辑期提示”,不等于“运行时一定存在”。

也就是说,这种代码:

const baseUrl = process.env.DATABASE_URL;

即便类型上声明为 string,如果部署环境没配,运行时还是会出问题。所以项目里通常还会再补一层启动校验,确保关键环境变量真的存在。

第三方库缺类型时怎么处理

这是老项目和边缘库接入时的另一个高频问题。

情况 1:库本身没带类型,但社区有 @types

优先装官方社区类型包:

pnpm add -D @types/lodash

这永远比自己先手写一份声明更稳。

情况 2:没有现成类型,只想先让项目继续写下去

可以先补一个最小声明:

declare module "legacy-chart-lib";

这能先把“无法找到模块声明”的错误压住,但它的代价也很明确:你失去了这个库的类型信息。

所以这类写法更适合过渡,而不是最终状态。

情况 3:你知道库的大致导出形状

这时更推荐写一个稍微有信息量的声明,而不是直接全量 any

declare module "legacy-chart-lib" {
export type ChartOptions = {
width?: number;
height?: number;
};

export function createChart(el: HTMLElement, options?: ChartOptions): void;
}

这样至少你在项目里的调用位置还能保住一部分类型能力。

情况 4:静态资源或非代码文件导入

这类也很常见,比如导入 svgpngmd 文件时,项目可能会报“找不到模块类型”。

可以这样补:

declare module "*.svg" {
const src: string;
export default src;
}

declare module "*.png" {
const src: string;
export default src;
}

放在哪

通常放在:

  • types/global.d.ts
  • types/modules.d.ts
  • src/types/assets.d.ts

重点不是文件名,而是要让它进入 tsconfig.jsoninclude 范围。

常见坑

any 用太快

any 不是完全不能用,但它应该被当成临时过渡,而不是长期默认值。

把“断言”当成“类型设计”

const user = data as User;

这种写法可以救急,但它只是告诉 TypeScript“你先相信我”,并没有真的帮你验证数据。

只配 TS,不配工具链

最常见的问题之一就是:tsconfig 里有 alias,但 Vite/Jest/Node 没同步,最后编辑器不报错,运行时报错。

只补声明,不做运行时校验

环境变量、接口数据、第三方库返回值,这几类都很容易让人误以为“类型都写了,就安全了”。其实类型系统负责的是静态约束,运行时边界还是要自己兜住。

一条实用路线

如果你现在准备把 TypeScript 接入一个普通项目,可以按这个顺序走:

  1. 安装 TypeScript 和对应类型依赖
  2. tsconfig.json
  3. 打开 strictnoImplicitAnystrictNullChecks
  4. baseUrl / paths
  5. typecheck 脚本
  6. 从配置、接口、工具函数开始迁移
  7. 再进入组件和业务层

下一步看什么