TypeScript 中的类

给面向对象编程加上类型保护

🎯 学习目标

学完本章,你将能够:

  • 能够使用 public、private、protected、readonly 修饰类成员
  • 能够定义抽象类与抽象方法约束子类行为
  • 能够使用 implements 实现接口约束与 override 安全重写
  • 能够理解 Mixin 模式实现多重行为组合
  • 能够运用类装饰器、方法装饰器和属性装饰器扩展元数据

先看一个问题

JavaScript 的 class 没有类型检查,属性可以随便加、随便改。如何让 class 也享受类型安全?

// JavaScript:一切都很自由,自由到危险
class User {
    constructor(name, age) {
        this.name = name;
        // 忘记赋值 this.age?不会报错
    }
}

const user = new User("张三", 25);
user.namme = "李四"; // 拼写错误,不会报错
user.age = "二十六"; // 类型错误,不会报错

TypeScript 为 class 添加了完整的类型注解能力:属性必须声明、方法有返回类型、访问修饰符控制可见性。

Part 1: 属性类型

属性必须先声明类型,才能在构造器中赋值

JavaScript 的 class 随便加属性,拼写错了也不报错。TypeScript 要求你在类体中先声明每个属性的类型——漏了就报错。这看似麻烦,实则帮你在编译期挡住了大量低级错误。我们从最基础的属性声明和构造器开始。

属性声明

在类体中声明,构造器中初始化

class Person {
    name: string;      // 必须声明类型
    age: number;       // 必须声明类型

    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }

    greet(): string {
        return `你好,我是${this.name},今年${this.age}岁`;
    }
}

const p = new Person("张三", 25);
// strictPropertyInitialization 开启后
// 属性必须在构造器中初始化,或者声明为可选
class Config {
    host: string;       // 必须在 constructor 中赋值
    port?: number;      // 可选属性,可以不初始化
    timeout: number;

    constructor(host: string, timeout: number) {
        this.host = host;
        this.timeout = timeout;
    }
}

// 确定赋值断言(当你知道属性会在外部初始化)
class App {
    private instance!: HTMLElement; // ! 告诉 TS 一定会被赋值
    mount() {
        this.instance = document.getElementById("app")!;
    }
}

Part 2: 访问控制

public、private、protected、readonly——控制谁能访问什么

属性声明好了,但谁有权访问?密码字段不该暴露给外部,角色信息子类要用但外部不该碰。四个访问修饰符让你精确控制每个属性和方法的可见性。再加上 getter/setter 和参数属性的简写,类的设计既安全又简洁。

四个修饰符

控制属性和方法的可见性

class User {
    public name: string;           // 公开(默认)
    private password: string;      // 仅类内部
    protected role: string;        // 类内部 + 子类
    readonly id: string;           // 只读

    constructor(name: string, password: string, role: string) {
        this.id = crypto.randomUUID();
        this.name = name;
        this.password = password;
        this.role = role;
    }
}

const user = new User("张三", "secret", "admin");
console.log(user.name);       // OK
// console.log(user.password); // 报错!private
// console.log(user.role);     // 报错!protected
修饰符类内部子类外部
public可以可以可以
protected可以可以不可以
private可以不可以不可以
图:四种访问修饰符的可见性范围

getter 与 setter

用属性的方式访问,用方法的方式控制

class User {
    private _name: string;

    constructor(name: string) {
        this._name = name;
    }

    // getter——读取时自动调用
    get displayName(): string {
        return `@${this._name}`;
    }

    // setter——赋值时自动调用
    set displayName(val: string) {
        this._name = val.replace("@", "");
    }
}

const user = new User("张三");
console.log(user.displayName);  // "@张三"(调用 getter)
user.displayName = "@李四";      // 调用 setter
console.log(user.displayName);  // "@李四"

getter/setter 看起来像属性访问,实际是方法调用。适合在读写时加入验证逻辑或计算逻辑。

参数属性

在构造器参数前加修饰符,自动创建属性

// 参数属性——构造器参数前加修饰符
class User {
    constructor(
        public name: string,          // 自动创建 public name
        private password: string,     // 自动创建 private password
        protected role: string,       // 自动创建 protected role
        readonly id: string = crypto.randomUUID(),
    ) {
        // 无需手动 this.name = name 等
    }
}

// 等同于手写版本(但更简洁)
class UserVerbose {
    public name: string;
    private password: string;
    protected role: string;
    readonly id: string;

    constructor(name: string, password: string, role: string) {
        this.name = name;
        this.password = password;
        this.role = role;
        this.id = crypto.randomUUID();
    }
}
注意:参数属性虽然简洁,但可读性较低。团队协作时,确认大家都习惯这种写法再使用。

Part 3: 静态成员

static 让属性和方法属于类本身,而不是实例

有些东西不该属于某个实例,而应该属于类本身——比如工具方法、版本号、单例实例。static 修饰的成员不需要 new 就能访问,直接通过类名调用。单例模式、工厂方法、静态初始化块都依赖它。

static 基础

属于类本身,不属于实例

class MathUtils {
    static PI = 3.14159;
    static readonly VERSION = "1.0"; // static + readonly = 常量

    static circleArea(radius: number): number {
        return MathUtils.PI * radius * radius;
    }
}

// 通过类名访问,不需要 new
console.log(MathUtils.PI);           // 3.14159
console.log(MathUtils.circleArea(5)); // 78.53975

// const m = new MathUtils();
// m.PI; // 报错!static 成员不在实例上
注意:static 成员属于类本身,不会被实例继承。子类可以通过 ChildClass.staticMethod() 访问父类的 static 成员,但它们挂载在类(构造函数)对象上,不在 prototype 链中。

static 进阶

单例模式与 static 初始化块

// 单例模式——private constructor + static getInstance
class UserService {
    private static instance: UserService | null = null;
    static readonly serviceName = "UserService";

    private constructor() {} // 禁止外部 new

    static getInstance(): UserService {
        if (!UserService.instance) {
            UserService.instance = new UserService();
        }
        return UserService.instance;
    }
}

const svc1 = UserService.getInstance();
const svc2 = UserService.getInstance();
console.log(svc1 === svc2); // true——永远是同一个实例
// static + 泛型——工厂方法
class Entity {
    static create<T extends Entity>(this: new () => T): T {
        return new this();
    }
}

// static 初始化块(ES2022+)——复杂静态逻辑
class Config {
    static readonly DB_URL: string;
    static readonly PORT: number;

    static {
        // 类加载时执行一次
        Config.DB_URL = process.env.DB_URL ?? "localhost:5432";
        Config.PORT = parseInt(process.env.PORT ?? "3000");
    }
}

Part 4: 抽象与实现

abstract class 和 implements——类的设计模式

多个子类共享同一段逻辑,但又各有各的实现细节——怎么设计?抽象类定义通用行为,子类负责填充具体实现。配合 override 关键字,方法重写更安全;配合 implements,类可以同时满足多个接口的约束。这是面向对象设计的核心工具。

抽象类

不能实例化,只能被继承的基类

// 抽象类——定义通用行为,强制子类实现细节
abstract class Animal {
    constructor(public name: string) {}

    // 抽象方法——子类必须实现
    abstract makeSound(): string;

    // 具体方法——子类直接继承
    describe(): string {
        return `${this.name}说: ${this.makeSound()}`;
    }
}

// const a = new Animal("动物"); // 报错!不能实例化抽象类

class Dog extends Animal {
    makeSound(): string { return "汪汪!"; }
}

class Cat extends Animal {
    makeSound(): string { return "喵~"; }
}

const dog = new Dog("旺财");
console.log(dog.describe()); // "旺财说: 汪汪!"

抽象属性

抽象类不仅能约束方法,还能约束属性

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

abstract class Repository<T> {
    abstract tableName: string;   // 子类必须声明表名
    abstract primaryKey: string;  // 子类必须声明主键

    abstract findById(id: string): Promise<T>;

    // 具体方法可以使用抽象属性
    async query(sql: string): Promise<T[]> {
        console.log(`Querying ${this.tableName}...`);
        return [] as T[];
    }
}

class UserRepository extends Repository<User> {
    tableName = "users";
    primaryKey = "id";

    async findById(id: string): Promise<User> {
        return this.query(
            `SELECT * FROM ${this.tableName} WHERE ${this.primaryKey} = '${id}'`
        ).then(rows => rows[0]);
    }
}

抽象属性强制子类提供配置信息,配合具体方法实现"模板方法"模式——基类定流程,子类填细节。

override 关键字

TS 4.3+ 显式标记方法重写

class Animal {
    move(): void { console.log("moving"); }
    eat(): void { console.log("eating"); }
}

class Dog extends Animal {
    override move(): void {  // OK——move 存在于基类
        console.log("running");
    }

    override bark(): void { // 报错!bark 不存在于 Animal
        console.log("woof");
    }
}
// tsconfig.json 中开启 noImplicitOverride
// {
//   "compilerOptions": {
//     "noImplicitOverride": true
//   }
// }
// 开启后,所有重写父类的方法都必须加 override

// 为什么用 override?
// 1. 防止拼写错误:wriet() 不会静默创建新方法
// 2. 基类改名/删除方法时,子类立刻报错
// 3. 代码意图更清晰——一眼就知道这是重写
最佳实践:始终为子类重写的方法添加 override。配合 noImplicitOverride 可以完全杜绝"方法名拼写错误"导致的隐蔽 bug。

implements 实现

类承诺满足接口的形状

// implements——类实现接口
interface Serializable {
    serialize(): string;
}

interface Loggable {
    log(): void;
}

// 一个类可以实现多个接口
class UserEntity implements Serializable, Loggable {
    constructor(public name: string, public age: number) {}

    serialize(): string {
        return JSON.stringify({ name: this.name, age: this.age });
    }

    log(): void {
        console.log(this.serialize());
    }
}
关键词作用是否获得实现
extends继承另一个类是(获得代码复用)
implements实现接口否(纯类型约束)

Part 5: 泛型类

让类也能参数化类型,适配不同数据

第28章学了泛型函数和泛型接口,泛型类是拼图的最后一块。数据容器、Repository 模式、Mixin 组合——这些都离不开泛型类。它让类像函数一样可以"传类型参数",一个 DataStore 类就能同时服务 User、Post、Order。

泛型数据容器

一个类适配多种数据类型

// 泛型数据容器
class DataStore<T> {
    private items: T[] = [];

    add(item: T): void { this.items.push(item); }
    get(index: number): T | undefined { return this.items[index]; }
    getAll(): T[] { return [...this.items]; }
    find(predicate: (item: T) => boolean): T | undefined {
        return this.items.find(predicate);
    }
    get count(): number { return this.items.length; }
}

const userStore = new DataStore<{ name: string; age: number }>();
userStore.add({ name: "张三", age: 25 });
userStore.add({ name: "李四", age: 30 });
userStore.find(u => u.name === "张三"); // 找到了

泛型类 + 约束

限制泛型参数必须满足某个接口

// 泛型类 + 接口约束
interface Identifiable { id: string; }

class Repository<T extends Identifiable> {
    private items = new Map<string, T>();

    add(item: T): void {
        this.items.set(item.id, item);
    }

    findById(id: string): T | undefined {
        return this.items.get(id);
    }

    remove(id: string): boolean {
        return this.items.delete(id);
    }
}

interface Post extends Identifiable {
    id: string;
    title: string;
    content: string;
}

const postRepo = new Repository<Post>();
postRepo.add({ id: "p1", title: "第一篇", content: "Hello" });
postRepo.findById("p1"); // Post | undefined

Mixin 模式

用函数组合类的功能,替代多继承

// TypeScript 不支持多继承(extends 只能一个)
// Mixin 模式:用函数给类"混入"额外功能

// 时间戳 Mixin
function Timestamped<T extends new (...args: any[]) => {}>(Base: T) {
    return class extends Base {
        createdAt = new Date();
        updatedAt = new Date();
    };
}

// 软删除 Mixin
function SoftDeletable<T extends new (...args: any[]) => {}>(Base: T) {
    return class extends Base {
        deletedAt: Date | null = null;
        softDelete(): void { this.deletedAt = new Date(); }
        get isDeleted(): boolean { return this.deletedAt !== null; }
    };
}

// 组合使用——比继承更灵活
class Article {
    constructor(public title: string, public content: string) {}
}

const EnhancedArticle = SoftDeletable(Timestamped(Article));
const article = new EnhancedArticle("标题", "内容");
article.softDelete();
console.log(article.createdAt);    // Date
console.log(article.isDeleted);    // true

Mixin 的核心思想:每个 Mixin 是一个高阶函数,接收一个类,返回一个扩展后的新类。组合顺序不影响功能,但影响类型推断。

继承与实现

extends 和 implements 有什么本质区别?

图:extends 与 implements 的区别

简单记忆:extends 拿到的是代码(实现),implements 拿到的是契约(约束)。一个类只能 extends 一个父类,但可以 implements 多个接口。

类表达式

class 不只能声明,还能当值用

// 匿名类赋值给变量
const MyComponent = class {
    render(): string { return "<div>Hello</div>"; }
};

const comp = new MyComponent();
console.log(comp.render()); // "<div>Hello</div>"

// 类表达式配合接口
interface StorageLike<T> {
    get(): T;
    set(value: T): void;
}

// 工厂函数返回类
const createStorage = <T>(initial: T): StorageLike<T> => {
    return class {
        private data: T = initial;
        get(): T { return this.data; }
        set(value: T): void { this.data = value; }
    };
};

const numStorage = createStorage(42);
numStorage.set(100);
console.log(numStorage.get()); // 100

类表达式常见于工厂模式——根据参数动态创建不同配置的类。也常用于 Mixin 模式的返回值(前面讲的 Timestamped 就是类表达式)。

Part 6: 装饰器

用函数"装饰"类和方法——Angular/NestJS 的核心机制

如果你用过 Angular 或 NestJS,一定见过 @Component、@Get 这样的写法。装饰器就是在不修改原有代码的情况下,给类和方法"加料"。它是 JavaScript TC39 Stage 3 提案,属于进阶内容,但在框架开发中几乎绕不开。

类装饰器

用一个函数包装整个类

// 类装饰器——接收类的构造函数
function Log(target: Function) {
    console.log(`类已创建: ${target.name}`);
}

@Log
class MyClass {}

// 实例化时不会触发,类定义时触发
// 控制台输出: "类已创建: MyClass"

// 带参数的装饰器——装饰器工厂
function Version(version: string) {
    return function (target: Function) {
        (target as any).version = version;
    };
}

@Version("2.0.0")
class API {}
console.log((API as any).version); // "2.0.0"
注意:使用装饰器需要在 tsconfig.json 中开启 "experimentalDecorators": true。TC39 Stage 3 新提案的语法有所不同,但目前主流框架(Angular、NestJS)仍在使用"实验性装饰器"。

方法装饰器

增强或拦截类的方法

// 方法装饰器——拦截方法调用
function Deprecated(message: string) {
    return function (
        target: any,
        key: string,
        descriptor: PropertyDescriptor
    ) {
        const original = descriptor.value;
        descriptor.value = function (...args: any[]) {
            console.warn(`警告: ${key} 已弃用。${message}`);
            return original.apply(this, args);
        };
    };
}

// 只读装饰器——防止方法被覆盖
function readonly(
    target: any,
    key: string,
    descriptor: PropertyDescriptor
) {
    descriptor.writable = false;
}

class APIService {
    @Deprecated("请使用 fetchUsers 替代")
    getUsers(): string[] { return ["张三", "李四"]; }

    @readonly
    getConfig(): object { return { version: "1.0" }; }
}

const api = new APIService();
api.getUsers(); // 控制台警告: "getUsers 已弃用。请使用 fetchUsers 替代"

属性装饰器

拦截属性的声明,添加元数据

// 用到 Reflect 元数据的装饰器,需要先安装并引入 polyfill:
//   $ npm i reflect-metadata
//   import "reflect-metadata";   // 在入口文件顶部引入一次即可
// 并在 tsconfig 开启:"emitDecoratorMetadata": true, "experimentalDecorators": true

// 属性装饰器——监听属性声明
function Range(min: number, max: number) {
    return function (target: any, key: string) {
        // 将验证规则存入元数据
        const metaKey = `__range_${key}__`;
        Object.defineProperty(target, metaKey, {
            value: { min, max },
            enumerable: false,
        });
    };
}

function Required(target: any, key: string) {
    const metaKey = `__required__`;
    // Reflect.getMetadata 来自 reflect-metadata(标准 Reflect 只有 get,没有 getMetadata)
    const existing: string[] = Reflect.getMetadata
        ? Reflect.getMetadata(metaKey, target) || []
        : (target[metaKey] || []);
    existing.push(key);
}

class UserForm {
    @Range(0, 150)
    age: number = 0;

    @Required
    name: string = "";
}

// 装饰器在类定义时执行,不是实例化时
// 常用于表单验证、ORM 映射、依赖注入

属性装饰器无法直接修改属性值,但可以通过 Object.defineProperty 或元数据(Reflect.metadata)记录信息,供运行时框架读取。

装饰器实战

Angular / NestJS / TypeORM 中的装饰器

// Angular——组件装饰器
@Component({
    selector: "app-root",
    template: "<h1>{{ title }}</h1>",
})
class AppComponent {
    @Input() title = "";
}

// NestJS——路由 + 依赖注入
@Controller("users")
class UserController {
    @Get(":id")
    findOne(@Param("id") id: string) { /* ... */ }
}

// TypeORM——实体映射
@Entity("users")
class User {
    @PrimaryGeneratedColumn()
    id: number;

    @Column({ length: 100 })
    name: string;

    @CreateDateColumn()
    createdAt: Date;
}
框架装饰器类型核心用途
Angular@Component, @Input, @Output组件元数据与绑定
NestJS@Controller, @Get, @Injectable路由与依赖注入
TypeORM@Entity, @Column, @OneToOne数据库表映射
前提:使用装饰器需在 tsconfig.json 开启 "experimentalDecorators": true 和 "emitDecoratorMetadata": true(后者用于 NestJS 依赖注入的类型推断)。

类 vs 函数式

何时用 class,何时用普通函数?

// 函数式风格——简单、组合方便
type User = { id: string; name: string; age: number };
const createUser = (name: string, age: number): User => ({
    id: crypto.randomUUID(), name, age,
});
const greet = (user: User): string => `你好,${user.name}`;

// OOP 风格——封装状态、行为与数据绑定
class UserService {
    private users = new Map<string, User>();
    add(user: User): void { this.users.set(user.id, user); }
    find(id: string): User | undefined { return this.users.get(id); }
}
场景推荐原因
纯数据变换函数输入 → 输出,无副作用
有状态的服务class封装内部状态,方法操作数据
React 组件函数Hooks 时代,函数组件为主
Node.js 后端看情况Service 用 class,工具用函数

链表练习

练习

任务:实现一个类型安全的链表类 LinkedList<T>,包含 add、remove、find、toArray 方法。

// 提示:需要一个内部 Node 类
class ListNode<T> {
    constructor(
        public value: T,
        public next: ListNode<T> | null = null
    ) {}
}

// 你的代码:
class LinkedList<T> {
    private head: ListNode<T> | null = null;
    private _size = 0;

    // add(item: T): void
    // remove(item: T): boolean
    // find(predicate: (item: T) => boolean): T | undefined
    // toArray(): T[]
    // get size(): number
}

参考答案

class LinkedList<T> {
    private head: ListNode<T> | null = null;
    private _size = 0;

    add(item: T): void {
        const node = new ListNode(item);
        if (!this.head) { this.head = node; }
        else {
            let curr = this.head;
            while (curr.next) curr = curr.next;
            curr.next = node;
        }
        this._size++;
    }

    remove(item: T): boolean {
        if (!this.head) return false;
        if (this.head.value === item) {
            this.head = this.head.next;
            this._size--; return true;
        }
        let curr = this.head;
        while (curr.next) {
            if (curr.next.value === item) {
                curr.next = curr.next.next;
                this._size--; return true;
            }
            curr = curr.next;
        }
        return false;
    }

    find(predicate: (item: T) => boolean): T | undefined {
        let curr = this.head;
        while (curr) {
            if (predicate(curr.value)) return curr.value;
            curr = curr.next;
        }
        return undefined;
    }

    toArray(): T[] {
        const result: T[] = [];
        let curr = this.head;
        while (curr) { result.push(curr.value); curr = curr.next; }
        return result;
    }

    get size(): number { return this._size; }
}

常见问题

问题一:属性未初始化报错

class App {
    name: string; // 报错!属性没有初始化
}

// 解决方案(三选一)
class App1 { name: string = ""; }          // 默认值
class App2 { name!: string; }              // 确定赋值断言
class App3 { name?: string; }              // 可选属性

问题二:private 和 # 的区别

class A {
    private x = 1;   // TS 编译期的 private,编译后属性名仍是 x
    #y = 2;          // ES 真正的 private,运行时也安全
}

// private 只是编译期检查,编译后产物里仍是 this.x,运行时可被访问
// # 是 ECMAScript 标准,运行时也完全不可访问

建议:新项目优先使用 # 真正的私有字段。

this 绑定

类方法中的 this 丢失问题

class Timer {
    private seconds = 0;

    // 问题:普通函数中 this 指向不正确
    startBad() {
        setInterval(function () {
            this.seconds++; // 报错!this 不是 Timer
        }, 1000);
    }

    // 修复 1:箭头函数自动绑定外层 this
    startGood() {
        setInterval(() => {
            this.seconds++; // OK——箭头函数没有自己的 this
        }, 1000);
    }

    // 修复 2:箭头函数属性(class field)——永久绑定
    tick = () => {
        this.seconds++;
    };
}

const timer = new Timer();
// 回调场景:方法作为参数传递时 this 可能丢失
setTimeout(timer.tick, 1000); // OK——tick 是箭头函数,已绑定
常见坑:事件监听、定时器、Promise 回调中直接传方法名会导致 this 丢失。解决方式:箭头函数属性(推荐)、.bind(this)、或在回调中使用箭头函数包裹。

试一试

练习一

任务:定义一个 BankAccount 类,有 owner(公开)、balance(私有)属性,以及 deposit 和 withdraw 方法。

// owner: string(公开)
// balance: number(私有,初始为 0)
// deposit(amount: number): void
// withdraw(amount: number): boolean
// getBalance(): number
// 你的代码:

参考答案

class BankAccount {
    constructor(public owner: string) {}
    private balance: number = 0;

    deposit(amount: number): void {
        if (amount <= 0) throw new Error("金额必须大于0");
        this.balance += amount;
    }

    withdraw(amount: number): boolean {
        if (amount > this.balance) return false;
        this.balance -= amount;
        return true;
    }

    getBalance(): number {
        return this.balance;
    }
}

练习二

任务:用参数属性重写上面的 BankAccount,同时定义一个抽象类 Account,让 BankAccount 继承它。

// abstract class Account { ... }
// class BankAccount extends Account { ... }
// 你的代码:

参考答案

abstract class Account {
    abstract getBalance(): number;
    abstract deposit(amount: number): void;
}

class BankAccount extends Account {
    constructor(public owner: string, private balance: number = 0) {
        super();
    }

    getBalance(): number { return this.balance; }
    deposit(amount: number): void { this.balance += amount; }
}

📝 要点总结

本章核心知识点

修饰符

public/private/protected 访问控制,readonly 只读

抽象与继承

abstract 抽象类/方法,extends 继承,override 安全重写

接口实现

implements 约束类结构,多接口实现

Mixin 模式

通过函数组合实现多重行为继承

装饰器

类/方法/属性装饰器,元数据反射,实验性特性

下一章

TypeScript 的类让 OOP 更安全。下一章我们学习模块与声明文件——让类型在项目之间传递。

1 / X