TypeScript 鸭子类型(结构化类型)

很多从 Java、C# 等传统后端语言转来学习 TypeScript 的小伙伴,在刚接触 TypeScript 的面向对象时,经常会经历三观崩塌的时刻:TypeScript 的类和接口,怎么看起来 “一点都不严谨” 呢?

比如我们定义了一个 User 类,并在函数中严格规定必须传入 User 的实例。但在实际调用时,如果我们没有使用 new User(),而是直接塞了一个长得一模一样的普通对象进去,TypeScript 竟然直接也放行了!

这到底是 TypeScript 的设计缺陷,还是另有玄机?今天,我们就来深度扒一下 TypeScript 面向对象中最核心的底层哲学:鸭子类型(Duck Typing)

TypeScript 鸭子类型是什么?

在编程世界里,关于如何判断一个变量到底属于什么类型,存在着两大流派:

  • 名义类型系统:以 Java、C++ 为代表。它们极其看重 “血统”。如果函数要求传入 User 类的对象,那你必须是实打实通过 new User() 生出来的才行,哪怕另一个对象长得和你完全一样,只要名字(血统)不对,就直接报错拦截。
  • 结构化类型系统:以 TypeScript 为代表。它根本不在乎你的 “血统” 和名字,它只在乎你的 “形状(结构)”。

名义类型系统 vs 结构化类型系统

这就引出了编程界一句非常著名的名言,也就是所谓的鸭子类型(Duck Typing):

"If it walks like a duck and quacks like a duck, it must be a duck."
(如果一只动物走起来像鸭子,叫起来像鸭子,那么它就是鸭子。)

在 TypeScript 的眼中,只要你的属性和方法能对得上,它就认为你们是同一种东西。小伙伴们要记住这一点。

TypeScript 类与普通对象的 “指鹿为马”

我们先来看一个让传统后端老手 “大跌眼镜” 的真实业务代码。

示例 1:鸭子类型在类中的体现

// 定义类
class User {
    public name: string;
    public age: number;

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

// 参数要求是 User 类的实例
function printUser(user: User): void {
    console.log(`${user.name}${user.age} 岁`);
}

// 传入 User 实例
const realUser = new User("Jack", 20);
printUser(realUser);

// 传入普通对象(骚操作)
const fakeUser = {
    name: "Lucy",
    age: 18,
    hobby: "Coding"
};

// 竟然完全不报错
printUser(fakeUser);

运行结果如下。

Jack20Lucy18

分析:

上面这个例子,就是 TypeScript “只看形状、不看血统” 的典型体现。

在上面的代码中,fakeUser 只是一个普通字面量对象,它并不是通过 new User() 创建出来的。但是,当我们将 fakeUser 传给 printUser() 时,TypeScript 编译器在底层做了一次“安检”:

  • User 类需要一个 name(字符串)和一个 age(数字)。
  • fakeUser 刚好也有 name(字符串)和 age (数字)。虽然它还多了一个 hobby,但这并不影响大局。
  • 既然你长得像 User(走起来像鸭子),那 TypeScript 就直接把你当成 User 放行了!

此外,fakeUser 能够成功伪装成 User 类实例的一个核心前提是:User 类的所有属性都是公有的(public)。

如果一个类中包含了 private 或 protected 私有属性,TypeScript 的“安检”标准就会瞬间变得极其严苛,立刻化身为 “名义类型系统”,只认真实的血统(实例)。

提示: public、private、protected 这几个是 TypeScript 的访问修饰符,另请参阅:TypeScript 访问修饰符

示例 2:私有属性打破鸭子类型

class SecureUser {
    // 包含一个私有属性
    private id: string = "U-1001";
    public name: string;

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

function printSecureUser(user: SecureUser): void {
    console.log(user.name);
}

// 哪怕长得一模一样,甚至连私有属性也原封不动地写进去
const fakeSecureUser = {
    id: "U-1001",
    name: "Lucy"
};

// 依然会被无情拦截!
// printSecureUser(fakeSecureUser);
// 报错:类型中缺少属性 "id",但类型 "SecureUser" 中需要该属性。

TypeScript 接口的兼容性

在 TypeScript 中,鸭子类型的理念不仅体现在类中,在接口(interface)互相赋值时,也遵循着相似的规律:“属性多的” 可以赋值给 “属性少的”(只要基础条件满足即可)。

示例 3:接口的兼容

interface Animal {
    name: string;
}

interface Dog {
    name: string;
    breed: string;    // 独有属性:品种
}

let myAnimal: Animal;

let myDog: Dog = {
    name: "大黄",
    breed: "中华田园犬"
};

// 把 Dog 赋值给 Animal,完全合法
myAnimal = myDog;
console.log(myAnimal.name);

运行结果如下。

大黄

分析:

在这个例子中,Animal 接口只要求有 name 属性。而 myDog 不仅有 name,还有额外的 breed。TypeScript 会认为 myDog 已经满足了 Animal 的所有最低要求(即长得像动物),所以允许赋值。

但反过来就不行了,因为 myAnimal 缺少了 Dog 必须拥有的 breed 属性,比如:

// 反过来:把 Animal 赋值给 Dog,直接报错
myDog = myAnimal; 
// 报错:类型 "Animal" 中缺少属性 "breed",但类型 "Dog" 中需要该属性

TypeScript 鸭子类型的 “小陷阱”

学到这里,有些大聪明小伙伴可能会想要去业务里尝试一种非常危险的代码写法,结果下一秒钟就被 TypeScript 狠狠打了脸。

示例 4:多余属性检查陷阱

interface Point {
    x: number;
    y: number;
}

function drawPoint(p: Point) {
    console.log("坐标:", p.x, p.y);
}

// 场景 1:先把对象存进变量,再传进去(没毛病)
const obj1 = { x: 10, y: 20, z: 30 };
drawPoint(obj1); 

// 场景 2:直接把对象字面量硬塞进去(直接报错)
drawPoint({ x: 10, y: 20, z: 30 });
// 报错:对象字面量只能指定已知属性,并且 “z” 不在类型 “Point” 中。

分析:

很多小伙伴在这里会直接懵逼:这 TypeScript 怎么还带双重标准的?为什么存进变量 obj1 里再传进去就不报错(走鸭子类型逻辑),直接写对象字面量 “{ ... }” 传进去就报错呢?

这是因为 TypeScript 底层有一个非常聪明的机制,叫做 “多余属性检查(Excess Property Checks)”。

当我们直接把一个包含了多余属性(如 z)的对象字面量赋值给变量或当做参数传递时,TypeScript 会觉得非常可疑:“你明明只要求 x 和 y,却当场硬塞给我一个 z,你是不是单词拼错了,或者传错对象了?” 为了安全,它会直接报错拦截。

而当我们先把对象赋值给一个变量 obj1 时,TypeScript 会认为这个 obj1 可能在其他地方还有别的用途,所以只要它的形状 “兼容” Point 接口,就会通过鸭子类型原则,并宽容放行。

给站长反馈

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

邮箱:lvyenet@vip.qq.com

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