JavaScript内核与高级编程之:`TypeScript`的类型系统:`Type Inference`和`Structural Subtyping`。

各位观众老爷们,大家好!今天咱们来聊聊TypeScript类型系统里的两位“老熟人”——类型推断(Type Inference)和结构化类型(Structural Subtyping)。这俩哥们儿在TypeScript里那可是顶梁柱级别的存在,理解它们能让你写代码的时候更加得心应手,bug少到可以忽略不计(理论上!)。

开场白:类型系统,并非枷锁,而是助燃剂

可能有些同学一听到“类型系统”就觉得头大,觉得这玩意儿限制了自由,捆住了手脚。但其实,一个好的类型系统就像是汽车的ABS系统,不是为了限制你飙车,而是为了在你高速行驶的时候,最大限度地保证你的安全,让你能更快、更稳地到达目的地。

TypeScript的类型系统就是这样的存在。它不是要束缚你的创造力,而是要在编译阶段就帮你发现潜在的问题,减少运行时错误的发生,提高代码的可维护性和可读性。

第一幕:类型推断——“猜猜我是谁?”

类型推断,顾名思义,就是TypeScript编译器能够自动推断出变量、参数、函数返回值的类型,而不需要你显式地声明。这大大简化了代码的编写,让代码更加简洁。

  1. 变量声明时的类型推断

    最常见的场景就是变量声明的时候。如果你在声明变量的同时就给它赋了值,TypeScript就会根据这个值来推断变量的类型。

    let message = "Hello, TypeScript!"; // TypeScript推断message的类型为string
    let age = 30; // TypeScript推断age的类型为number
    const isAdult = true; // TypeScript推断isAdult的类型为boolean

    在这个例子中,我们没有显式地声明messageageisAdult的类型,但是TypeScript通过它们的值,聪明地推断出了它们的类型。

    如果你试图给message赋一个数字,TypeScript就会毫不留情地报错:

    message = 123; // Error: Type 'number' is not assignable to type 'string'.

    这就像你的管家,时刻盯着你,防止你犯错。

  2. 函数返回值类型推断

    TypeScript也能根据函数的返回值来推断函数的返回类型。

    function add(a: number, b: number) {
       return a + b; // TypeScript推断add的返回类型为number
    }
    
    const sum = add(10, 20); // TypeScript推断sum的类型为number

    在这个例子中,我们没有显式地声明add函数的返回类型,但是TypeScript通过return a + b这个表达式,推断出add函数的返回类型是number

    如果函数没有返回值,TypeScript会推断它的返回类型为void

    function greet(name: string) {
       console.log(`Hello, ${name}!`); // 没有显式返回值
    }
    
    let greeting = greet("Alice"); // TypeScript推断greeting的类型为void
  3. 最佳通用类型推断 (Best Common Type)

    当从几个表达式中推断类型时,会使用这些表达式的类型来计算出一个 "最佳通用类型"。 例如,从下面这些表达式中进行推断:

    let arr = [1, 2, null]; // 推断arr的类型为 (number | null)[]

    为了推断arr的类型,TypeScript会考虑数组里每个成员的类型。 在这里,TypeScript会从numbernull中推断出最合适的通用类型,也就是 (number | null)

    如果找不到最佳通用类型,TypeScript就会退而求其次,推断出any类型。

    let x = [1, "hello"]; // TypeScript推断x的类型为 (string | number)[]

    有时候,你可能需要手动指定类型,来避免TypeScript推断出any类型。

  4. 类型推断的局限性

    虽然类型推断很强大,但它也不是万能的。在某些复杂的情况下,TypeScript可能无法正确地推断出类型,或者推断出的类型不是你想要的。

    例如,考虑下面的情况:

    let employee = {
       name: "Bob",
       title: "Software Engineer"
    };
    
    employee.age = 30; // Error: Property 'age' does not exist on type '{ name: string; title: string; }'.

    在这个例子中,TypeScript根据初始值推断出employee的类型为{ name: string; title: string; }。这意味着employee对象只有nametitle两个属性,没有age属性。

    如果你想要给employee对象添加age属性,你需要显式地声明employee的类型,或者使用类型断言。

    // 显式声明类型
    let employee: { name: string; title: string; age?: number } = {
       name: "Bob",
       title: "Software Engineer"
    };
    
    employee.age = 30; // OK
    
    // 类型断言
    let employee2 = {
       name: "Bob",
       title: "Software Engineer"
    } as any;
    
    employee2.age = 30; // OK (但不推荐,会绕过类型检查)

    总而言之,类型推断是一个强大的工具,但你需要了解它的局限性,并在必要的时候显式地声明类型。

第二幕:结构化类型——“长得像就是一家人”

结构化类型,也称为“鸭子类型”(Duck Typing),它的核心思想是:如果一个对象具有你所需要的属性和方法,那么它就是你想要的类型,而不需要显式地声明它实现了某个接口或继承了某个类。

这就像一句古老的谚语:“如果它走起路来像鸭子,叫起来也像鸭子,那么它就是一只鸭子。”

  1. 接口的实现

    在Java或C#等语言中,一个类必须显式地声明它实现了某个接口,才能被认为是该接口的类型。但是在TypeScript中,只要一个对象具有接口中定义的所有属性和方法,它就被认为是该接口的类型,而不需要显式地声明。

    interface Point {
       x: number;
       y: number;
    }
    
    function printPoint(point: Point) {
       console.log(`x: ${point.x}, y: ${point.y}`);
    }
    
    let myPoint = {
       x: 10,
       y: 20,
       z: 30 // 额外的属性没关系
    };
    
    printPoint(myPoint); // OK,myPoint具有x和y属性,所以可以作为Point类型传递

    在这个例子中,myPoint对象没有显式地声明它实现了Point接口,但是它具有Point接口中定义的xy属性,所以它可以作为Point类型传递给printPoint函数。

    注意,myPoint对象还可以有额外的属性(例如z),这并不影响它作为Point类型使用。

    但是,如果myPoint对象缺少Point接口中定义的属性,TypeScript就会报错。

    let invalidPoint = {
       x: 10
    };
    
    printPoint(invalidPoint); // Error: Argument of type '{ x: number; }' is not assignable to parameter of type 'Point'.
    //   Property 'y' is missing in type '{ x: number; }' but required in type 'Point'.
  2. 类的兼容性

    结构化类型也适用于类的兼容性。只要一个类具有另一个类所需要的所有属性和方法,它就可以作为另一个类的类型使用,而不需要显式地继承。

    class Animal {
       name: string;
       constructor(name: string) {
           this.name = name;
       }
       move(distanceInMeters: number = 0) {
           console.log(`${this.name} moved ${distanceInMeters}m.`);
       }
    }
    
    class Dog {
       name: string;
       constructor(name: string) {
           this.name = name;
       }
       move(distanceInMeters: number = 5) {
           console.log(`${this.name} moved ${distanceInMeters}m.`);
       }
    }
    
    let animal: Animal = new Dog("Rover"); // OK,Dog具有Animal所需要的所有属性和方法
    animal.move(); // 输出 "Rover moved 0m."

    在这个例子中,Dog类没有显式地继承Animal类,但是它具有Animal类所需要的namemove属性和方法,所以它可以作为Animal类型使用。

    但是,如果Dog类缺少Animal类所需要的属性或方法,TypeScript就会报错。

    class Cat {
       // 缺少name属性
       move(distanceInMeters: number = 10) {
           console.log(`Cat moved ${distanceInMeters}m.`);
       }
    }
    
    let animal2: Animal = new Cat(); // Error: Property 'name' is missing in type 'Cat' but required in type 'Animal'.
  3. 结构化类型的优势和劣势

    结构化类型的主要优势是灵活性。它允许你使用任何具有所需属性和方法的对象,而不需要关心它的具体类型。这使得代码更加简洁,更容易重用。

    但是,结构化类型也有一些劣势。由于类型检查是基于结构的,而不是基于名称的,因此可能会出现一些意想不到的类型兼容性问题。

    例如,考虑下面的情况:

    interface Person {
       name: string;
       age: number;
    }
    
    interface Product {
       name: string;
       price: number;
    }
    
    let person: Person = {
       name: "Alice",
       age: 30
    };
    
    let product: Product = person; // OK,因为person对象具有name属性
    
    console.log(product.price); // 运行时错误:product.price is undefined

    在这个例子中,Person接口和Product接口都有一个name属性,所以person对象可以赋值给product变量,而不会报错。但是,product对象没有price属性,所以在运行时会发生错误。

    为了避免这种问题,你需要更加小心地使用结构化类型,并确保你的代码逻辑是正确的。

第三幕:类型兼容性——“你中有我,我中有你”

类型兼容性是指一个类型的值可以赋值给另一个类型的变量。在TypeScript中,类型兼容性是基于结构化类型的。也就是说,如果一个类型的所有成员都兼容于另一个类型,那么这两个类型就是兼容的。

  1. 基本类型的兼容性

    基本类型(例如numberstringboolean)的兼容性比较简单。

    • number类型可以赋值给number类型。
    • string类型可以赋值给string类型。
    • boolean类型可以赋值给boolean类型。
    • any类型可以赋值给任何类型,任何类型也可以赋值给any类型。
    • nullundefined类型可以赋值给任何类型,除了--strictNullChecks标志设置为true的情况。
  2. 对象类型的兼容性

    对象类型的兼容性是基于结构化类型的。如果一个对象类型的所有成员都兼容于另一个对象类型,那么这两个对象类型就是兼容的。

    interface Animal {
       name: string;
    }
    
    interface Dog {
       name: string;
       breed: string;
    }
    
    let animal: Animal;
    let dog: Dog = { name: "Rover", breed: "Labrador" };
    
    animal = dog; // OK,Dog类型兼容于Animal类型,因为Dog类型具有Animal类型的所有成员
    
    dog = animal; // Error: Property 'breed' is missing in type 'Animal' but required in type 'Dog'.

    在这个例子中,Dog类型兼容于Animal类型,因为Dog类型具有Animal类型的所有成员(name属性)。但是,Animal类型不兼容于Dog类型,因为Animal类型缺少Dog类型所需要的breed属性。

  3. 函数类型的兼容性

    函数类型的兼容性比较复杂。如果一个函数类型的所有参数类型都兼容于另一个函数类型的参数类型,并且它的返回值类型兼容于另一个函数类型的返回值类型,那么这两个函数类型就是兼容的。

    type StringToNumber = (str: string) => number;
    type StringToString = (str: string) => string;
    
    let stringToNumberFunc: StringToNumber;
    let stringToStringFunc: StringToString = (str: string) => str;
    
    //stringToNumberFunc = stringToStringFunc; // Error: Type 'StringToString' is not assignable to type 'StringToNumber'.
    // Type 'string' is not assignable to type 'number'.
    
    stringToStringFunc = stringToNumberFunc; // OK

    在这个例子中,StringToNumberStringToString 的参数类型一样,但是返回值类型不一样,导致了不兼容。

    参数的双向协变 (Bivariance)

    当比较函数参数类型时,只有在--strictFunctionTypes设置为true时才会进行严格的类型检查。否则,函数参数类型是双向协变的,也就是说,只要一个函数的参数类型可以赋值给另一个函数的参数类型,或者另一个函数的参数类型可以赋值给这个函数的参数类型,这两个函数类型就是兼容的。

    interface Event {
       timestamp: number;
    }
    
    interface MouseEvent extends Event {
       x: number;
       y: number;
    }
    
    type EventCallback = (e: Event) => void;
    type MouseEventCallback = (e: MouseEvent) => void;
    
    let eventCallback: EventCallback;
    let mouseEventCallback: MouseEventCallback = (e: MouseEvent) => console.log(e.x, e.y);
    
    eventCallback = mouseEventCallback; // OK (在非 strictFunctionTypes 模式下)
    //mouseEventCallback = eventCallback; // Error (在 strictFunctionTypes 模式下)

    可选参数和剩余参数

    当比较函数类型时,可选参数和剩余参数会被特殊处理。

    • 具有可选参数的函数类型可以赋值给没有可选参数的函数类型。
    • 具有剩余参数的函数类型可以赋值给没有剩余参数的函数类型。

第四幕:类型断言——“我说了算!”

类型断言是一种告诉TypeScript编译器“我知道我在做什么,相信我”的方式。当你比编译器更了解某个值的类型时,可以使用类型断言来覆盖编译器的推断。

  1. 类型断言的语法

    类型断言有两种语法:

    • 尖括号语法:<Type>value
    • as 语法:value as Type

    推荐使用as语法,因为它更清晰,也更不容易与JSX语法冲突。

    let someValue: any = "this is a string";
    
    let strLength: number = (someValue as string).length; // 使用as语法
    let strLength2: number = (<string>someValue).length; // 使用尖括号语法
  2. 类型断言的使用场景

    类型断言通常用于以下场景:

    • 当你需要访问一个联合类型中某个特定类型的属性或方法时。
    • 当你需要将一个any类型的值转换为一个更具体的类型时。
    • 当你需要覆盖编译器的类型推断,并强制指定一个值的类型时。
    // 访问联合类型中某个特定类型的属性或方法
    interface Bird {
       fly(): void;
       layEggs(): void;
    }
    
    interface Fish {
       swim(): void;
       layEggs(): void;
    }
    
    function getRandomPet(): Bird | Fish {
       // ...
       return {} as Bird
    }
    
    let pet = getRandomPet();
    
    if ((pet as Bird).fly) {
       (pet as Bird).fly();
    } else if ((pet as Fish).swim) {
       (pet as Fish).swim();
    }
    
    // 将any类型的值转换为一个更具体的类型
    const element = document.getElementById("myCanvas") as HTMLCanvasElement;
    const ctx = element.getContext("2d");
  3. 类型断言的风险

    类型断言是一种强大的工具,但也需要谨慎使用。如果你错误地断言了一个值的类型,可能会导致运行时错误。

    let x: any = "hello";
    console.log((x as number).toFixed(2)); // 运行时错误:x is not a number

    因此,在使用类型断言之前,你需要确保你真的了解这个值的类型,并且你的断言是正确的。

第五幕:类型守卫——“我是类型保镖!”

类型守卫是一种在运行时检查值的类型,并缩小其类型范围的技术。类型守卫通常用于处理联合类型,让你可以在不同的代码分支中安全地访问特定类型的属性或方法。

  1. typeof类型守卫

    typeof类型守卫使用typeof运算符来检查值的类型。

    function printValue(value: string | number) {
       if (typeof value === "string") {
           console.log(value.toUpperCase()); // 在这个分支中,value的类型被缩小为string
       } else {
           console.log(value.toFixed(2)); // 在这个分支中,value的类型被缩小为number
       }
    }
  2. instanceof类型守卫

    instanceof类型守卫使用instanceof运算符来检查值是否是某个类的实例。

    class Animal {
       name: string;
       constructor(name: string) {
           this.name = name;
       }
    }
    
    class Dog extends Animal {
       breed: string;
       constructor(name: string, breed: string) {
           super(name);
           this.breed = breed;
       }
    }
    
    function printAnimal(animal: Animal) {
       if (animal instanceof Dog) {
           console.log(animal.breed); // 在这个分支中,animal的类型被缩小为Dog
       } else {
           console.log(animal.name); // 在这个分支中,animal的类型被缩小为Animal
       }
    }
  3. 自定义类型守卫

    你可以使用类型谓词(Type Predicate)来定义自定义类型守卫。类型谓词是一种返回value is Type的函数,它告诉TypeScript编译器,如果函数返回true,那么value的类型就是Type

    interface Bird {
       fly(): void;
       layEggs(): void;
    }
    
    interface Fish {
       swim(): void;
       layEggs(): void;
    }
    
    function isBird(pet: Bird | Fish): pet is Bird {
       return (pet as Bird).fly !== undefined;
    }
    
    function getRandomPet(): Bird | Fish {
       return {} as Bird
    }
    
    let pet = getRandomPet();
    
    if (isBird(pet)) {
       pet.fly(); // 在这个分支中,pet的类型被缩小为Bird
    } else {
       pet.swim(); // 在这个分支中,pet的类型被缩小为Fish
    }

结语:类型系统,你的代码守护神

好了,今天关于TypeScript的类型推断和结构化类型的讲座就到这里。希望大家通过今天的学习,能够更加深入地理解TypeScript的类型系统,并在实际开发中灵活运用这些知识,写出更加健壮、可维护的代码。

记住,类型系统不是你的敌人,而是你的朋友,是你的代码守护神。善用类型系统,可以让你少踩很多坑,少熬很多夜。

感谢大家的观看!咱们下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注