MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

TypeScript中的类型兼容性与类型检查

2021-08-114.2k 阅读

一、TypeScript类型兼容性基础概念

在TypeScript的世界里,类型兼容性起着至关重要的作用。它决定了在赋值、函数调用等场景下,一种类型的值是否可以被另一种类型所接受。理解类型兼容性,有助于我们编写出更健壮、更灵活的代码。

1.1 赋值兼容性

在TypeScript中,当我们进行变量赋值时,赋值语句右边的值的类型必须与左边变量的类型兼容。例如:

let num: number;
let anotherNum: number = 10;
num = anotherNum;

这里anotherNum的类型是number,与num的类型完全匹配,所以赋值是合法的。

但是,如果我们尝试将一个string类型的值赋给num

let num: number;
let str: string = 'hello';
// num = str; // 报错:Type'string' is not assignable to type 'number'.

此时TypeScript会报错,因为string类型与number类型不兼容。

1.2 结构类型系统

TypeScript采用结构类型系统(也称为鸭子类型系统)。这意味着,只要两个类型的结构相同,它们就是兼容的,而不要求它们具有相同的类型声明。

例如,我们有两个接口Point1Point2

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

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

let p1: Point1 = { x: 1, y: 2 };
let p2: Point2 = p1; // 合法,因为Point1和Point2结构相同

尽管Point1Point2是不同的接口声明,但由于它们具有相同的结构(都有xy属性且类型为number),所以它们是兼容的。

二、类型兼容性详细规则

2.1 原始类型兼容性

原始类型包括numberstringbooleannullundefined等。

  • numberstring:如前文所述,numberstring类型不兼容。
let num: number;
let str: string = '1';
// num = str; // 报错
  • boolean与其他类型boolean类型与numberstring等类型不兼容。
let bool: boolean = true;
let num: number = 1;
// bool = num; // 报错
  • nullundefined:在严格模式下,nullundefined仅与自身以及void类型兼容。
let value: null = null;
let anotherValue: undefined = undefined;
let voidValue: void;
value = anotherValue; // 合法,在严格模式下
voidValue = null; // 合法,在严格模式下

在非严格模式下,nullundefined可以赋值给任何类型。

2.2 数组类型兼容性

数组类型兼容性主要基于数组元素类型的兼容性。

  • 相同元素类型数组:如果两个数组的元素类型相同,那么它们是兼容的。
let nums1: number[] = [1, 2, 3];
let nums2: number[] = nums1; // 合法
  • 不同元素类型数组:如果元素类型不兼容,则数组类型不兼容。
let nums: number[] = [1, 2, 3];
let strs: string[] = ['a', 'b', 'c'];
// strs = nums; // 报错:Type 'number[]' is not assignable to type'string[]'.
  • 数组与元组:元组是一种特殊的数组,它有固定数量和类型的元素。如果数组的元素类型与元组的元素类型兼容,且长度不超过元组的长度,那么数组可以赋值给元组。
let tuple: [number, string] = [1, 'a'];
let arr: (number | string)[] = [1, 'a'];
tuple = arr; // 报错:Type '(number | string)[]' is not assignable to type '[number, string]'.

这里虽然数组元素类型与元组元素类型有交集,但由于数组长度不确定,所以不能赋值给元组。

2.3 对象类型兼容性

对于对象类型,TypeScript会比较对象的属性。

  • 属性少的对象与属性多的对象:如果一个对象类型的属性是另一个对象类型属性的子集,那么属性少的对象类型可以赋值给属性多的对象类型。
interface Animal {
    name: string;
}

interface Dog extends Animal {
    age: number;
}

let animal: Animal = { name: 'Tom' };
let dog: Dog = { name: 'Jerry', age: 2 };
animal = dog; // 合法,因为Dog的属性包含Animal的属性
  • 额外属性检查:在对象字面量赋值时,TypeScript会进行额外属性检查。例如:
interface Person {
    name: string;
}

// let person: Person = { name: 'John', age: 30 }; // 报错:Object literal may only specify known properties, and 'age' does not exist in type 'Person'.

这里对象字面量{ name: 'John', age: 30 }包含了Person接口中不存在的age属性,所以会报错。但如果我们通过一个变量来赋值,就不会有额外属性检查:

interface Person {
    name: string;
}

let obj = { name: 'John', age: 30 };
let person: Person = obj; // 合法,因为没有直接使用对象字面量

2.4 函数类型兼容性

函数类型兼容性较为复杂,涉及参数和返回值类型。

  • 参数类型兼容性:在函数赋值或作为参数传递时,赋值目标函数的参数类型必须能接受源函数的参数类型。这意味着源函数的参数类型要更具体(或相同)。
let func1 = (num: number) => num;
let func2 = (value: number | string) => value;
func2 = func1; // 合法,因为func1的参数类型number是func2参数类型number | string的子集

相反:

let func1 = (num: number | string) => num;
let func2 = (value: number) => value;
// func2 = func1; // 报错:Type '(num: number | string) => number | string' is not assignable to type '(value: number) => number'. Types of parameters 'num' and 'value' are incompatible. Type 'number' is not assignable to type 'number | string'.

这里func1的参数类型更宽泛,不能赋值给func2

  • 返回值类型兼容性:源函数的返回值类型必须与目标函数的返回值类型兼容,即源函数的返回值类型要更具体(或相同)。
let func1 = (): number => 1;
let func2 = (): number | string => 'a';
// func2 = func1; // 报错:Type '() => number' is not assignable to type '() => number | string'. Type 'number' is not assignable to type 'number | string'.

这里func1的返回值类型numberfunc2的返回值类型number | string更具体,所以不能赋值。

三、类型检查机制

3.1 静态类型检查

TypeScript的核心特性之一就是静态类型检查。在编译阶段,TypeScript编译器会根据我们定义的类型注解,检查代码中类型的一致性。

例如:

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

let result = addNumbers(1, '2'); // 报错:Argument of type'string' is not assignable to parameter of type 'number'.

在上述代码中,addNumbers函数期望两个number类型的参数,而我们传递了一个string类型的参数,TypeScript编译器在编译时就会报错。

3.2 类型推断

TypeScript具有强大的类型推断能力。在很多情况下,即使我们没有显式地声明类型,TypeScript也能根据上下文推断出变量或表达式的类型。

  • 变量声明时的类型推断
let num = 10; // TypeScript推断num的类型为number

这里我们没有显式声明num的类型,但TypeScript根据初始值10推断出num的类型是number

  • 函数返回值类型推断
function getMessage() {
    return 'Hello, world!';
}
let message = getMessage(); // TypeScript推断message的类型为string

getMessage函数没有显式声明返回值类型,但TypeScript根据return语句中的值推断出返回值类型为string,进而推断出message的类型为string

3.3 类型断言

有时候,我们可能比TypeScript编译器更清楚某个值的类型,这时可以使用类型断言来告诉编译器我们期望的类型。

  • “尖括号”语法
let someValue: any = 'this is a string';
let strLength: number = (<string>someValue).length;

这里我们使用<string>someValue断言为string类型,这样就可以访问length属性。

  • as语法
let someValue: any = 'this is a string';
let strLength: number = (someValue as string).length;

as语法与“尖括号”语法功能相同,在JSX代码中只能使用as语法进行类型断言。

四、高级类型兼容性与检查场景

4.1 泛型中的类型兼容性

泛型为TypeScript带来了强大的代码复用能力,同时也涉及到类型兼容性。

  • 泛型函数的兼容性:当两个泛型函数的类型参数相同时,它们的兼容性规则与普通函数相同。
function identity<T>(arg: T): T {
    return arg;
}

function anotherIdentity<T>(arg: T): T {
    return arg;
}

let id1 = identity;
let id2 = anotherIdentity;
id2 = id1; // 合法,因为两个泛型函数结构相同
  • 泛型类型参数的兼容性:在泛型类型中,类型参数的兼容性会影响整个类型的兼容性。
interface Box<T> {
    value: T;
}

let box1: Box<number> = { value: 1 };
let box2: Box<number | string> = { value: 'a' };
// box1 = box2; // 报错:Type 'Box<number | string>' is not assignable to type 'Box<number>'. Type 'number | string' is not assignable to type 'number'.

这里Box<number>Box<number | string>不兼容,因为number | string不是number的子类型。

4.2 交叉类型与联合类型的兼容性

  • 交叉类型兼容性:交叉类型A & B表示同时具有AB的所有属性。如果一个类型与交叉类型的每个组成部分都兼容,那么它与交叉类型兼容。
interface A {
    a: string;
}

interface B {
    b: number;
}

let ab: A & B = { a: 'hello', b: 1 };
let obj1: A = { a: 'world' };
let obj2: B = { b: 2 };
// ab = obj1; // 报错:Type 'A' is missing the following properties from type 'A & B': b
// ab = obj2; // 报错:Type 'B' is missing the following properties from type 'A & B': a

这里obj1obj2分别只满足A & B的一部分,所以不能赋值给ab

  • 联合类型兼容性:联合类型A | B表示可以是A类型或者B类型。如果一个类型与联合类型中的某一个组成部分兼容,那么它与联合类型兼容。
let value: number | string;
let num: number = 1;
let str: string = 'a';
value = num; // 合法
value = str; // 合法

4.3 类型守卫与类型检查细化

类型守卫是一种运行时检查机制,用于缩小变量的类型范围,从而使类型检查更加细化。

  • typeof类型守卫
function printValue(value: number | string) {
    if (typeof value === 'number') {
        console.log(value.toFixed(2)); // 在这个块中,TypeScript知道value是number类型
    } else {
        console.log(value.toUpperCase()); // 在这个块中,TypeScript知道value是string类型
    }
}

这里通过typeof进行类型守卫,使得在不同的分支中,TypeScript能够准确地知道value的具体类型。

  • instanceof类型守卫:用于检查一个对象是否是某个类的实例。
class Animal {}
class Dog extends Animal {}

function handleAnimal(animal: Animal) {
    if (animal instanceof Dog) {
        console.log('This is a dog');
    } else {
        console.log('This is other animal');
    }
}

通过instanceof,在if块中我们可以确定animalDog类型,从而可以调用Dog类特有的方法。

五、类型兼容性与检查的最佳实践

5.1 合理使用类型注解

虽然TypeScript有类型推断能力,但在一些复杂场景下,显式的类型注解可以提高代码的可读性和可维护性。

  • 函数参数和返回值:对于公共函数,明确标注参数和返回值类型,避免他人调用时出现类型错误。
// 推荐
function calculateSum(a: number, b: number): number {
    return a + b;
}

// 不推荐,类型推断可能不清晰
function calculateSum(a, b) {
    return a + b;
}
  • 复杂对象类型:对于复杂的对象类型,使用接口或类型别名进行定义,并在变量声明时使用这些类型。
// 推荐
interface User {
    name: string;
    age: number;
}

let user: User = { name: 'John', age: 30 };

// 不推荐,类型不明确
let user = { name: 'John', age: 30 };

5.2 遵循类型兼容性规则

在编写代码时,要遵循TypeScript的类型兼容性规则,避免因类型不兼容导致的错误。

  • 函数赋值与调用:确保函数赋值和调用时,参数和返回值类型符合兼容性规则。
function greet(name: string) {
    return 'Hello,'+ name;
}

let otherGreet = (value: string | number) => {
    if (typeof value ==='string') {
        return 'Hi,'+ value;
    }
    return 'Hi, unknown';
};

// 正确使用,otherGreet的参数类型兼容greet的参数类型
let result1 = otherGreet('Tom');
// 错误使用,greet的参数类型不兼容otherGreet的参数类型
// let result2 = greet(123);

5.3 利用类型检查进行代码优化

通过类型检查,我们可以在编译阶段发现潜在的错误,从而优化代码。

  • 尽早发现错误:在项目开发初期,就启用严格的类型检查,尽早发现类型相关的错误,避免后期难以调试。
// 启用严格模式,在编译时发现错误
let num: number;
// num = 'a'; // 报错:Type'string' is not assignable to type 'number'.
  • 避免不必要的类型断言:虽然类型断言很有用,但过度使用可能会隐藏真实的类型错误。尽量让TypeScript通过正常的类型推断和检查来确定类型。
// 尽量避免不必要的类型断言
let someValue: any = 'this is a string';
// 可以通过其他方式处理,而不是直接断言
// let length: number = (someValue as string).length;

六、常见问题与解决方案

6.1 类型兼容性导致的意外行为

有时候,类型兼容性规则可能会导致一些意外行为。

  • 对象属性赋值问题:在对象属性赋值时,由于类型兼容性可能会出现值不符合预期的情况。
interface Shape {
    color: string;
}

interface Rectangle extends Shape {
    width: number;
    height: number;
}

let shape: Shape = { color: 'red' };
let rectangle: Rectangle = { color: 'blue', width: 10, height: 20 };
shape = rectangle;
// 此时shape的类型虽然是Shape,但实际值包含width和height属性,可能导致意外行为

解决方案是在涉及对象赋值时,仔细检查对象的属性和类型,确保不会因为类型兼容性而引入潜在问题。

6.2 类型检查不通过的常见原因

  • 类型注解错误:类型注解与实际值的类型不匹配。
let num: string = 10; // 报错:Type '10' is not assignable to type'string'.

解决方案是仔细检查类型注解,确保其与实际值的类型一致。

  • 函数参数和返回值类型不匹配:在函数调用或赋值时,参数和返回值类型不符合兼容性规则。
function divide(a: number, b: number): number {
    return a / b;
}

// 错误调用,第二个参数类型不匹配
let result = divide(10, '2');

解决方案是确保函数调用时传递的参数类型与函数定义的参数类型一致,以及函数返回值类型与期望的返回值类型兼容。

通过深入理解TypeScript中的类型兼容性与类型检查规则,并遵循最佳实践,我们能够编写出更健壮、更易于维护的前端代码,充分发挥TypeScript的优势,提升开发效率和代码质量。