首页 / 文章

TypeScript核心要点:JavaScript开发者的实战指南

面向 JavaScript 开发者的 TypeScript 实战指南:核心类型、tsconfig 配置、严格模式,以及逐步迁移方案,让代码日后更易于修改。

发布者:REACTAPP.TOP阅读约 18 分钟4117 词

来源: TypeScript for JavaScript Developers: A Practical Beginner’s Guide

如果你已经熟悉 JavaScript,学习 TypeScript 就不像是在学习一门全新的语言,而更像是扩展你已掌握的这门语言。

TypeScript 本质上是基于 JavaScript 加入了类型系统后的增强版本。

你依然可以继续使用变量、函数、数组、对象、类、Promise、async/await、模块以及所有其他你已熟悉的元素。TypeScript 的优势在于提供了一套工具,能够在代码在浏览器或服务器上运行之前就提前发现错误。

本指南将介绍对已有 JavaScript 开发经验的人最相关的 TypeScript 概念。

什么是 TypeScript?

TypeScript 是一种构建在 JavaScript 之上的语言。

在大多数情况下,你编写的任何 JavaScript 程序也都是有效的 TypeScript 代码。TypeScript 的优势在于它提供了静态类型检查功能,最终会将代码编译为可在浏览器和 Node.js 中运行的普通 JavaScript 代码。

以这段简单的 JavaScript 代码为例:

function add(a, b) {
  return a + b;
}

用 TypeScript 重写后,同一个函数可以声明它接受何种类型的值:

function add(a: number, b: number): number {
  return a + b;
}

具体说明如下:

  • a: number 要求 a 必须是数字类型。
  • b: number 要求 b 也必须是数字类型。
  • 末尾的 : number 表示该函数的返回值是数字类型。

有了这些注解,TypeScript 可以在代码执行之前就检测出错误的函数调用:

add(10, 20);      // Correct
add("10", "20");  // TypeScript error

在纯 JavaScript 中,使用字符串参数调用该函数会静默地返回 "1020",因为加号运算符会连接字符串而非对数字进行求和。TypeScript 能够提前发现这类意外行为。

为何要使用 TypeScript?

JavaScript 的灵活性是其优势,但同时也可能引发一些隐蔽的错误。

以这个函数为例:

function calculateDiscount(price, percentage) {
  return price - price * percentage / 100;
}

正确调用时,其效果如预期般正常:

calculateDiscount(1000, 10);

但你也完全可以这样调用它:

calculateDiscount("1000", "ten");

问题只有在代码实际执行时才会显现出来。

使用 TypeScript 后,你可以指定函数期望的参数类型:

function calculateDiscount(
  price: number,
  percentage: number
): number {
  return price - price * percentage / 100;
}

这样 TypeScript 就能在无需先运行代码的情况下立即标记出无效参数。

它带来的主要优势包括:

  • 在编写代码时就能发现错误
  • 编辑器中的自动补全功能得到提升
  • 重构更简单、更安全
  • 函数和对象的结构更加清晰
  • 在较大的代码库中进行协作更为顺畅
  • 类型本身即可作为内置文档

当项目文件数量超过少数几个或涉及多名贡献者时,TypeScript的优势就会显得尤为明显。

安装 TypeScript

可以通过以下方式全局安装 TypeScript:

npm install -g typescript

要确认已安装的版本,可运行:

tsc --version

然后创建一个名为:

app.ts

在其中添加一些 TypeScript 代码:

const message: string = "Hello TypeScript";
console.log(message);

使用以下命令编译该文件:

tsc app.ts

这样就会生成编译后的 JavaScript 输出:

app.js

你可以直接使用 Node.js 运行生成的结果:

node app.js

在生产环境中,React、Next.js、Angular 和 NestJS 等工具和框架通常会自动为你处理这个编译步骤。

tsconfig.json 文件

大多数 TypeScript 项目都依赖 tsconfig.json 文件来配置编译器。

你可以通过以下方式生成该文件:

tsc --init

最简的配置可能如下所示:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

以下是其中一些较为重要的设置:

target

该选项用于指定编译器输出的 JavaScript 版本。

"target": "ES2020"

module

该选项用于设置编译输出所使用的模块格式。

"module": "ESNext"

strict

开启此选项可启用更严格的类型检查。

"strict": true

对于任何新项目,强烈建议保持严格模式开启状态。

rootDir

该参数用于告知编译器 TypeScript 源文件的位置。

"rootDir": "./src"

outDir

它决定了编译后的 JavaScript 输出文件应保存的位置。

"outDir": "./dist"

基本类型

TypeScript 提供了多种你已熟悉的原始类型。

字符串

let username: string = "Vijeet";

数字

let experience: number = 10;

TypeScript 不区分整数和浮点数;二者都属于 number 类型。

let age: number = 35;
let price: number = 999.99;

布尔值

let isActive: boolean = true;

空值与未定义值

let emptyValue: null = null;
let missingValue: undefined = undefined;

在严格模式下,你需要更谨慎地处理 null 和 undefined。

类型推断

不必手动为每个值添加注释。

TypeScript 通常可以根据赋给变量的值来判断其类型。

let city = "Ahmedabad";

在这里,TypeScript 会自动将 city 识别为字符串。

尝试像这样重新赋值会引发错误:

city = 100;

这里的逻辑相同:

let total = 500;

TypeScript 推断 total 存储的是数字。

依靠类型推断可以让代码更简洁。只有在确实需要提高可读性的地方,比如函数参数、API响应结构以及较为复杂的对象结构中,才使用显式的类型注释。

数组

这是一个普通的 JavaScript 数组:

const skills = ["JavaScript", "React", "Node.js"];

TypeScript 允许你为数组的元素类型添加注解:

const skills: string[] = [
  "JavaScript",
  "React",
  "Node.js"
];

你也可以使用泛型语法来写它:

const skills: Array<string> = [
  "JavaScript",
  "React",
  "Node.js"
];

同样的原理也适用于数字数组:

const scores: number[] = [75, 82, 91];

TypeScript 会阻止任何尝试插入错误类型值的操作:

scores.push(95);       // Correct
scores.push("ninety"); // Error

对象

你可以为普通的 JavaScript 对象指定明确的结构。

const user: {
  name: string;
  age: number;
  isAdmin: boolean;
} = {
  name: "Vijeet",
  age: 35,
  isAdmin: true
};

如果某个属性缺失或类型不正确,TypeScript 会将其标记为错误。

const user: {
  name: string;
  age: number;
} = {
  name: "Vijeet"
};

这里,对象中缺少必需的 age 属性。

当需要在多个地方重复使用相同的对象结构时,接口或类型别名比每次都手动编写结构更为合适。

接口

接口定义了对象应具有的结构。

interface User {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
}

一旦定义,即可在任何地方重用该接口:

const user: User = {
  id: 1,
  name: "Vijeet",
  email: "vijeet@example.com",
  isActive: true
};

接口在以下场景中非常有用:

  • 描述用户的存储信息
  • 对目录项进行建模
  • 验证表单中输入的字段
  • 规范从 API 调用返回的数据结构
  • 指定传递给组件的属性类型
  • 表示数据库表中的一行数据

例如:

interface Product {
  id: number;
  title: string;
  price: number;
}

此时函数可以将 Product 作为参数接收:

function displayProduct(product: Product): void {
  console.log(`${product.title}: ₹${product.price}`);
}

可选属性

某些对象属性并非始终存在。

只需在属性名称后加上问号即可标记它们:

interface User {
  id: number;
  name: string;
  phone?: string;
}

以下两个对象都符合该接口的要求:

const userOne: User = {
  id: 1,
  name: "Amit"
};
const userTwo: User = {
  id: 2,
  name: "Neha",
  phone: "9876543210"
};

由于 phone 是可选的,其类型变为:

string | undefined

这意味着在使用它之前应先验证其是否存在:

if (userTwo.phone) {
  console.log(userTwo.phone);
}

类型别名

类型别名允许你为任何类型指定一个可重复使用的名称。

type UserId = number;

之后就可以将该别名当作类型来使用:

const id: UserId = 101;

在描述联合类型或更复杂的类型组合时,类型别名能发挥巨大作用。

type Status = "pending" | "approved" | "rejected";

使用此别名后,只允许三种特定的字符串:

let applicationStatus: Status = "pending";

尝试使用其他任何内容都会引发错误:

applicationStatus = "completed";

接口与类型别名

接口和类型别名都能用来描述对象的结构。

这是接口版本:

interface Customer {
  name: string;
  email: string;
}

以及对应的类型别名版本:

type Customer = {
  name: string;
  email: string;
};

给新手的一个大致准则:

  • 在描述对象或类结构时,使用 interface。
  • 在处理联合类型、原始类型的别名、元组或更复杂的组合类型时,使用 type。

不过这并非绝对规则,在很多情况下两种方法的效果都一样好。

联合类型

联合类型允许一个值取多种可能的类型中的一种。

let userId: string | number;

以下这两种赋值都是合法的:

userId = 101;
userId = "USR-101";

当来自现实世界的数据并不总是遵循固定格式时,联合类型非常有用。

function printId(id: string | number): void {
  console.log(id);
}

在调用仅存在于某种可能类型上的方法之前,需要先缩小值的范围:

function printId(id: string | number): void {
  if (typeof id === "string") {
    console.log(id.toUpperCase());
  } else {
    console.log(id.toFixed(0));
  }
}

这种技术被称为类型收窄。

字面量类型

字面量类型将变量限制在一组精确的、预定义的值中。

type Theme = "light" | "dark" | "system";

这种赋值是可行的:

let selectedTheme: Theme = "dark";

但这种则会被拒绝:

selectedTheme = "blue";

字面量类型非常适合以下场景:

  • 记录的当前状态
  • 分配给用户的权限等级
  • 视觉主题设置
  • 按钮的视觉风格
  • 请求中使用的HTTP动词
  • 应用程序的运行模式

例如:

type UserRole = "admin" | "editor" | "customer";

函数

对于函数参数,类型紧跟在参数名称之后。

function greet(name: string): string {
  return `Hello, ${name}`;
}

函数的返回类型位于参数列表的闭合括号之后。

function multiply(a: number, b: number): number {
  return a * b;
}

当函数不返回值时,用void进行标注。

function logMessage(message: string): void {
  console.log(message);
}

可选参数用 ? 标记:

function greetUser(
  name: string,
  title?: string
): string {
  if (title) {
    return `Hello, ${title} ${name}`;
  }
 return `Hello, ${name}`;
}

默认参数值的行为与普通 JavaScript 中完全相同:

function calculateTax(
  amount: number,
  rate: number = 18
): number {
  return amount * rate / 100;
}

箭头函数的类型标注

可以以相同方式对箭头函数进行标注。

const subtract = (
  a: number,
  b: number
): number => {
  return a - b;
};

这是一种更简洁的形式:

const subtract = (
  a: number,
  b: number
): number => a - b;

any 类型

将 any 类型赋值给某个变量,意味着告诉 TypeScript 完全停止对该变量的检查。

let data: any = "Hello";
data = 100;
data = true;
data = { name: "Vijeet" };

虽然这乍看之下很方便,但过度使用 any 会浪费 TypeScript 的大部分功能。

请看这个例子:

function processData(data: any): any {
  return data.someProperty.someMethod();
}

对于这个函数中的错误,TypeScript 无能为力。

仅在确实无法避免的情况下才使用 any —— 例如在逐步转换 JavaScript 代码库时,或是处理缺乏适当类型定义的第三方数据时。

unknown 类型

unknown 为表示那些类型在事先无法确定的值提供了一种更安全的方式。

let value: unknown;

与 any 一样,它也能接受各种类型的值:

value = "Hello";
value = 100;
value = true;

但与 any 不同,TypeScript 要求你在实际使用之前先限定该类型的范围:

if (typeof value === "string") {
  console.log(value.toUpperCase());
}

正因如此,unknown 非常适合用于:

  • 来自外部 API 的数据
  • 从 JSON 解析得到的值
  • 在 try/catch 块中捕获的错误对象
  • 用户输入的数据
  • 任何结构尚未确定的值

类型断言

有时,关于某个值的信息比 TypeScript 自行能推断的更多。

类型断言允许你告诉编译器如何处理该值。

const input = document.getElementById(
  "email"
) as HTMLInputElement;

一旦进行断言,就可以访问该类型特有的属性:

console.log(input.value);

请记住,断言不会在运行时改变任何内容——它仅会改变 TypeScript 在编译时对该值的解读方式。

因此,应谨慎使用断言,且仅在确定数据的实际结构时才使用。

元组

元组是一种具有固定数量位置的数组,每个位置都有其定义好的类型。

let userEntry: [number, string];
userEntry = [1, "Vijeet"];

此处,第一个位置必须存放数字,第二个位置必须存放字符串。

以下写法将会出错:

userEntry = ["Vijeet", 1];

元组非常适合处理小型、结构固定的数据或成对的值:

type Coordinate = [number, number];
const location: Coordinate = [23.0225, 72.5714];

当数据变得更为复杂或需要具有描述性的字段名时,普通对象通常更清晰。

枚举

枚举允许你定义一组固定的命名常量。

enum OrderStatus {
  Pending,
  Processing,
  Shipped,
  Delivered
}

你可以这样引用它们:

let status: OrderStatus = OrderStatus.Processing;

为枚举赋予字符串值通常能提高可读性:

enum PaymentStatus {
  Pending = "pending",
  Paid = "paid",
  Failed = "failed"
}

尽管如此,许多 TypeScript 用户仍更倾向于使用联合类型,尤其是在较简单的场景中:

type PaymentStatus =
  | "pending"
  | "paid"
  | "failed";

联合类型通常会生成更少的 JavaScript 代码,是大多数前端代码的理想默认选择。

类

TypeScript 在普通 JavaScript 类的基础上增加了类型检查功能。

class Employee {
  name: string;
  salary: number;
  constructor(name: string, salary: number) {
    this.name = name;
    this.salary = salary;
  }  getAnnualSalary(): number {
    return this.salary * 12;
  }
}

类的实例化方式与 JavaScript 中相同:

const employee = new Employee("Rahul", 50000);
console.log(employee.getAnnualSalary());

访问修饰符也是 TypeScript 类语法的一部分。

class BankAccount {
  public accountHolder: string;
  private balance: number;
  constructor(accountHolder: string, balance: number) {
    this.accountHolder = accountHolder;
    this.balance = balance;
  }  public getBalance(): number {
    return this.balance;
  }
}

主要的修饰符有:

  • public:可从任何地方访问
  • private:仅可在类内部访问
  • protected:可在类及其所有子类中访问
  • readonly:可设置一次,之后不可重新赋值

泛型

泛型能让您编写灵活且可重用的代码,同时在不同输入类型之间保持完全的类型安全。

以这个函数作为起点:

function returnValue(value: any): any {
  return value;
}

由于它使用了 any,TypeScript 无法告知其返回值的任何信息。

使用通用参数重写代码可以解决该问题:

function returnValue<T>(value: T): T {
  return value;
}

现在返回类型会与传入的类型保持一致:

const text = returnValue<string>("Hello");
const count = returnValue<number>(100);

在许多情况下,TypeScript可以自动推断出通用类型,因此无需手动指定:

const text = returnValue("Hello");
const count = returnValue(100);

在处理数组、API响应结构、可复用UI组件、自定义钩子以及辅助工具时,经常会用到通用类型。

function getFirstItem<T>(items: T[]): T | undefined {
  return items[0];
}

使用方式如下:

const firstNumber = getFirstItem([10, 20, 30]);
const firstName = getFirstItem(["Amit", "Neha"]);

Promise与异步函数

使用async声明的函数始终会返回Promise。

async function getUsername(): Promise<string> {
  return "Vijeet";
}

一个返回用户对象的异步函数可能长这样:

interface User {
  id: number;
  name: string;
}
async function getUser(): Promise<User> {
  return {
    id: 1,
    name: "Vijeet"
  };
}

而对于用户列表,则需要将返回类型封装在数组中:

async function getUsers(): Promise<User[]> {
  return [
    { id: 1, name: "Vijeet" },
    { id: 2, name: "Rahul" }
  ];
}

以这种方式为 API 返回值指定正确的类型,可以避免其他代码部分出现后续错误。

为 API 响应指定类型

想象一个返回产品记录的接口:

interface Product {
  id: number;
  title: string;
  price: number;
}

你可以将 fetch 调用封装在函数中,并在该函数中声明其返回类型:

async function fetchProducts(): Promise<Product[]> {
  const response = await fetch("/api/products");
  if (!response.ok) {
    throw new Error("Unable to fetch products");
  }  const data: Product[] = await response.json();  return data;
}

有一点需要记住:一旦应用程序开始运行,TypeScript 就无法验证这些数据了。

这行代码:

const data: Product[] = await response.json();

仅指示编译器将解析后的响应视为 Product[] —— 它并不能确保服务器确实返回了那种结构的对象。

对于那些正确性至关重要的应用程序,开发人员通常会将 TypeScript 与 Zod 等运行时验证库结合使用来弥补这一缺陷。

处理 DOM 元素

浏览器元素类型是 TypeScript 内置的。

const button = document.querySelector(
  "#submit-button"
);

编译器知道该查询可能会返回空结果,因此结果类型会同时包含元素类型和 null。

这意味着在使用之前应先处理缺失值的情况:

if (button) {
  button.addEventListener("click", () => {
    console.log("Button clicked");
  });
}

如果需要更具体的元素类型,可以对其进行断言:

const emailInput = document.querySelector(
  "#email"
) as HTMLInputElement | null;

在确认其存在后即可读取其值:

if (emailInput) {
  console.log(emailInput.value);
}

TypeScript 无法替代运行时验证

这一点尤其需要强调,尤其是对于直接从 JavaScript 转型而来的开发者。

TypeScript 的检查仅发生在开发阶段和编译阶段。一旦代码被编译为 JavaScript,所有的类型注解都会消失。

以这个函数为例:

function double(value: number): number {
  return value * 2;
}

编译后,它大致会变成这样:

function double(value) {
  return value * 2;
}

此时,已不存在可用于强制执行任何规则的原始类型信息了。

因此,TypeScript无法保证:

  • API确实返回了格式正确的数据
  • 用户输入的是有效值
  • 数据库记录符合预期的结构
  • 第三方服务遵守了其文档中规定的契约

任何来自代码外部的内容都仍需在程序运行时进行验证。

迁移现有的JavaScript项目

将JavaScript代码库转换为TypeScript并不一定非得一次性彻底重写。

实际操作中,逐步迁移的方式往往更有效。

步骤1:安装TypeScript

npm install --save-dev typescript

步骤2:创建tsconfig.json

npx tsc --init

步骤3:暂时允许使用JavaScript

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false
  }
}

第4步:逐步重命名文件

将某个文件像这样修改:

utils.js

变为:

utils.ts

对于React组件,则将:

Button.jsx

改为:

Button.tsx

第5步:一次修复一个文件中的错误

建议从工具函数、共享数据模型以及较小的组件开始处理,将更为复杂的应用逻辑留到后面解决。

这种逐步推进的方式正是TypeScript在集成到现有项目时所带来的最大实际优势之一。

JavaScript开发者常犯的错误

到处使用any

const user: any = getUser();

这虽然能暂时避免编译器的警告,但也会同时取消所有的类型检查。

更好的做法是定义一个实际的接口:

interface User {
  id: number;
  name: string;
}

为每个变量添加类型

没有必要写下这些内容:

const name: string = "Vijeet";
const count: number = 10;
const active: boolean = true;

因为类型推断已经能够自动确定这些类型:

const name = "Vijeet";
const count = 10;
const active = true;

只有在确实能提升清晰度的场合才使用显式注解。

忽略 null

这段代码在运行时可能会出现问题:

const element = document.querySelector("#app");
element.innerHTML = "Hello";

被查询的元素可能根本就不存在于页面上。

应当提前防范这种可能性:

const element = document.querySelector("#app");
if (element) {
  element.innerHTML = "Hello";
}

利用类型断言掩盖问题

编写这样的代码存在风险:

const user = apiResponse as User;

类型断言并不检查实际 API 响应的任何内容——它只是让编译器相信你的说法。

将 TypeScript 视为不同的运行时环境

TypeScript不会被浏览器直接执行。在运行之前,它会被编译成普通的JavaScript。

这意味着所有关于JavaScript的现有知识依然适用。

实际示例

想象一个简单的购物车功能,用通常的JavaScript方式实现,但现在加入了TypeScript类型定义。

interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

function calculateCartTotal(
  items: CartItem[]
): number {
  return items.reduce((total, item) => {
    return total + item.price * item.quantity;
  }, 0);
}
const cart: CartItem[] = [
  {
    id: 1,
    name: "Keyboard",
    price: 2500,
    quantity: 1
  },
  {
    id: 2,
    name: "Mouse",
    price: 1200,
    quantity: 2
  }
];
const total = calculateCartTotal(cart);
console.log(`Cart total: ₹${total}`);

有了这些类型定义后,该函数就能避免因购物车数据格式错误而出现问题。

例如,以下代码片段将无法编译:

const invalidCart: CartItem[] = [
  {
    id: 1,
    name: "Keyboard",
    price: "2500",
    quantity: 1
  }
];

问题在于price被设置为字符串,而它应该是数字。

每个JavaScript项目都需要使用TypeScript吗?

并非总是如此。

对于以下情况,普通的JavaScript依然可以正常使用:

  • 小型实验
  • 简单脚本
  • 快速原型开发
  • 基础着陆页
  • 临时内部工具
  • 在以下情况下,TypeScript 的优势更为明显:

    • 大型前端应用
    • React 和 Next.js 代码库
    • Node.js 后端服务
    • 跨团队共享的代码库
    • 长期运行的产品
    • 处理复杂数据结构的项目
    • 由多名开发者共同构建和维护的应用程序

    一般来说,项目规模越大、生命周期越长,采用 TypeScript 能带来的好处就越多。

    总结

    TypeScript 并不能替代 JavaScript —— 它通过为代码添加额外的安全层来提升编写 JavaScript 的体验。

    作为从 JavaScript 转型而来的人,你已经具备了大部分必要的基础知识:

    • 变量
    • 函数
    • 对象
    • 数组
    • 类
  • 承诺
  • 模块
  • 异步代码
  • 接下来需要学习的主要是如何表达数据的结构。

    良好的起点包括:

    1. 原始类型
    2. 函数的参数和返回类型
    3. 接口
    4. 联合类型
    5. 可选属性
    6. 泛型
    7. API的类型化响应

    不必试图立刻掌握所有高级特性。先从为日常的JavaScript代码添加注解开始,让实际项目的需求来指引你后续的学习方向。

    一旦TypeScript变得驾轻就熟,再回到纯的、无类型的JavaScript时,可能会感到出乎意料的不稳定。