TypeScript 接口继承

在 TypeScript 中,接口(interface)是用来约束对象的形状的。接口非常好用,但在实际开发中,我们经常会遇到 “属性复用” 的问题。

假设我们正在开发一个后台管理系统,系统里有普通用户(User),也有管理员(Admin)。普通用户有 name 和 age 属性;而管理员除了拥有普通用户的所有属性外,还有一个独有的 permissions(权限)属性。

如果不用继承,我们可能会写出下面这样的代码:

// 普通用户接口
interface User {
    name: string;
    age: number;
}

// 管理员接口(重复写了 name 和 age)
interface Admin {
    name: string;
    age: number;
    permissions: string[];
}

这种 “复制粘贴” 式的代码在大型项目中是非常危险的(违反了 DRY 原则)。万一以后所有用户都要新增一个 avatar(头像)属性,我们又得在几十个类似的接口里挨个去改,这样很容易漏改出错。

为了优雅地解决这个问题,TypeScript 为接口引入了传统的面向对象概念:接口继承(extends)

TypeScript 接口继承是什么?

接口继承,简单来说就是:一个子接口可以直接 “继承(拿来)” 父接口中的所有属性,然后再添加自己独有的属性。

在 TypeScript 中,我们可以使用 extends 关键字来实现接口继承。

语法:

interface 子接口 extends 父接口 {
    子接口独有属性1: 类型1;
    ...
}

示例 1:使用接口继承

// 定义父接口
interface User {
    name: string;
    age: number;
}

// 子接口继承父接口
interface Admin extends User {
    permissions: string[];
}

// 使用子接口创建对象
const currentAdmin: Admin = {
    name: "Jack",
    age: 20,
    permissions: ["add_user", "delete_user"]
};

console.log(currentAdmin.name);
console.log(currentAdmin.permissions[0]);

运行结果如下。

Jack
add_user

分析:

在这个例子中,Admin 接口通过 extends User,直接就继承拿到了 name 和 age 属性。然后我们在创建 currentAdmin 对象时,就必须严格提供这 3 个属性。这样一来,代码的复用性和可维护性瞬间提升了一个档次。

TypeScript 接口的多继承

在 TypeScript 中,接口不但可以实现单继承,还可以实现多继承。也就是说,一个子接口可以同时继承多个不同的父接口,把它们的属性全部 “吸纳” 过来。

其中,多个父接口之间需要使用英文逗号 “,” 隔开。

示例 2:接口的多继承

// 定义普通用户接口
interface User {
    name: string;
}

// 定义 VIP 用户接口
interface VipPrivilege {
    discount: number;
}

// 接口实现多继承
interface VipUser extends User, VipPrivilege {
    level: number;
}

const currentVip: VipUser = {
    name: "Jack",
    discount: 0.8,
    level: 3
};

console.log("VIP 折扣:", currentVip.discount);

运行结果如下。

VIP 折扣:0.8

分析:

通过使用接口多继承的方式,VipUser 完美融合了用户基础信息、VIP特权信息以及自身的等级信息。这种像搭积木一样的组合方式,可以使得我们的类型定义变得极其灵活。

TypeScript 同名属性的覆盖(重写)

有小伙伴可能会想到一种极端情况:“如果在继承的时候,子接口里写了一个和父接口同名的属性,会发生什么呢?”

在 TypeScript 中,子接口是允许重写父接口同名属性的,但前提是:子接口中重写的属性类型,必须是父接口属性类型的 “子集”(即能够兼容)。

示例 3:同名属性的重写

interface Parent {
    id: string | number;    // 联合类型
    hobby?: string;         // 可选属性
}

interface Child extends Parent {
    id: number;          // 缩小了类型范围(合法)
    hobby: string;       // 去掉了可选标志,变成必填(合法)
    // age: boolean;     // 假设父接口有 age: number,这里改成 boolean 就会直接报错
}

const childData: Child = {
    id: 1001,
    hobby: "Coding"
};

console.log(childData.id);

运行结果如下。

1001

分析:

因为 number 属于 “string | number”(这是一个联合类型)的一部分,所以 TypeScript 是允许这种重写的。这在业务中常用于 “将宽泛的父类型、在子类型中进行收窄”。但如果我们试图把一个完全不相干的类型覆盖上去,则 TypeScript 会直接报错拦截。

TypeScript 接口继承类

在绝大多数编程语言中,接口只能继承接口。但 TypeScript 提供了一个非常强大的隐藏玩法:接口可以继承类(Class)。

当一个接口继承了一个类时,它会把类里面所有的属性和方法签名(长什么样)都继承过来,但不包含具体的代码实现。

示例 4:接口继承类

// 定义一个基础的 UI 组件类
class BaseComponent {
    width: number = 0;
    height: number = 0;
    
    // 这是一个方法
    render() {
        console.log("执行渲染逻辑");
    }
}

// 接口直接继承这个类
interface ButtonProps extends BaseComponent {
    text: string;    // 按钮独有的文字属性
}

// 使用该接口约束对象
const submitButton: ButtonProps = {
    width: 100,
    height: 40,
    text: "提交",
    // 必须提供 render 方法,但不要求和类里面的实现一模一样
    render() {
        console.log("渲染提交按钮");
    }
};

console.log(submitButton.text);

运行结果如下。

提交

分析:

使用接口继承类,这种写法在前端框架(如 Vue 或 React)的源码中经常出现。当我们想要把一个已有的复杂类结构直接拿来作为数据约束的 “形状”,而又不想实例化那个类时,这种高级继承方式能省去大量重复定义接口的时间。

私有属性的继承限制

在 TypeScript 的高级面向对象开发中,如果被继承的类(比如上例中的 BaseComponent)包含了 private(私有)或 protected(受保护)的属性,那么这个接口就会变得非常 “高冷”:它只能被这个类自己,或者这个类的 “子类” 所使用。

如果我们试图用一个普通的大括号 “{}”(字面量对象)去直接赋值给这个接口类型,TypeScript 会严格拦截并报错:“类型缺少私有属性”。这种机制极大地保护了企业级底层框架中核心类的内部状态不被外部随意伪造。

TypeScript 接口继承 vs 交叉类型

学到这里,很多小伙伴肯定会有疑问:“上一节学过的交叉类型(&)也能合并多个类型,那么它和接口继承(extends)到底有什么区别?”实际上,这也是高级前端面试中出场率非常高的一道面试题。

接口继承与交叉类型之间最大的区别在于:遇到同名属性冲突时,它们有着完全不同的处理机制。

  • 接口继承(extends):显得非常刚烈。如果父子接口有同名属性且类型不兼容,则 TypeScript 会直接在编写阶段抛出错误。
  • 交叉类型(&):显得非常隐忍。如果两个类型有同名属性且不兼容,它不会报错,而是会默默地把这个属性的类型变成 never

示例 5:接口继承 vs 交叉类型

interface A {
    value: string;
}

// 使用接口继承:直接报错
interface B extends A {
    value: number; 
    // 报错:接口“B”错误扩展接口“A”。属性“value”的类型不兼容。
}

// 使用交叉类型:不报错,但 value 变成了 never
type C = { value: string } & { value: number };
const testData: C = {
    value: "Jack"
    // 报错:不能将类型“string”分配给类型“never”。
};

分析:

在实际开发中,当定义组件或实体模型、且结构比较固定时,我们强烈推荐优先使用 “接口继承(interface + extends)”。因为它的错误提示更早、更清晰,编译性能也更好。

而当我们需要临时动态地将两个第三方类型合并时,再考虑使用 “交叉类型(type + &)”。

什么是 DRY 原则?

DRY 原则,全称是 “Don't Repeat Yourself”(直译为:不要重复你自己)。

DRY 是软件开发中极为经典且基础的一个设计原则,最早是在《程序员修炼之道》(The Pragmatic Programmer)这本书中被提出的。

它的核心思想是:系统中的每一份业务逻辑、数据结构或代码,都应该有唯一的、明确的、权威的表述。简单来说就是:拒绝复制粘贴,尽量复用代码。

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号