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

TypeScript代码审查常见类型问题清单

2021-01-194.4k 阅读

类型定义不明确

在 TypeScript 代码审查中,类型定义不明确是较为常见的问题。这种不明确会导致代码的可读性变差,并且在后续维护和扩展时容易引发各种错误。

未明确函数参数和返回值类型

在函数定义时,如果不明确参数和返回值类型,那么 TypeScript 的类型检查优势就无法体现。例如:

// 错误示例
function add(a, b) {
    return a + b;
}

上述代码中,add 函数没有明确 ab 的参数类型,也未指定返回值类型。这在大型项目中,其他开发者调用这个函数时,很难知晓参数的具体要求。正确的做法是明确类型:

// 正确示例
function add(a: number, b: number): number {
    return a + b;
}

这里明确了 abnumber 类型,并且返回值也是 number 类型,这样代码的意图就清晰多了。

变量类型推断不准确

TypeScript 具有类型推断功能,但有时会出现推断不准确的情况。比如:

let value;
if (Math.random() > 0.5) {
    value = 'hello';
} else {
    value = 123;
}
// 这里 value 的类型被推断为 string | number
console.log(value.length); 
// 报错:类型“string | number”上不存在属性“length”。类型“number”上不存在属性“length”。

在上述代码中,由于 value 初始未赋值,之后根据条件赋值为不同类型,TypeScript 将其推断为联合类型 string | number。但当尝试访问 length 属性时,就会报错,因为 number 类型没有 length 属性。

为了避免这种情况,可以在定义变量时就明确类型:

let value: string;
if (Math.random() > 0.5) {
    value = 'hello';
} else {
    // 这里会报错,因为 value 被定义为 string 类型
    value = 123; 
}
console.log(value.length); 

类型兼容性问题

TypeScript 的类型兼容性规则有时会让开发者产生混淆,进而导致一些不易察觉的错误。

赋值兼容性

在 TypeScript 中,赋值兼容性遵循一定规则。例如,子类型可以赋值给父类型,而父类型不能赋值给子类型。

class Animal {}
class Dog extends Animal {}

let animal: Animal = new Dog(); 
// 正确,Dog 是 Animal 的子类型,可以赋值

let dog: Dog = new Animal(); 
// 错误,Animal 不是 Dog 的子类型,不能赋值

然而,在涉及函数类型的赋值兼容性时,情况会稍微复杂一些。对于函数参数,是逆变的,而返回值是协变的。

type AnimalFunction = (a: Animal) => void;
type DogFunction = (d: Dog) => void;

let animalFunc: AnimalFunction;
let dogFunc: DogFunction = (d) => {};

animalFunc = dogFunc; 
// 正确,因为函数参数是逆变的,Dog 是 Animal 的子类型,
// 一个接受 Dog 的函数可以赋值给接受 Animal 的函数

let anotherDogFunc: DogFunction;
let anotherAnimalFunc: AnimalFunction = (a) => {};

anotherDogFunc = anotherAnimalFunc; 
// 错误,函数参数逆变,接受 Animal 的函数不能赋值给接受 Dog 的函数

接口兼容性

接口之间的兼容性主要看成员是否兼容。

interface A {
    x: number;
}
interface B {
    x: number;
    y: string;
}

let a: A = { x: 1 };
let b: B = { x: 1, y: 'test' };

a = b; 
// 正确,B 包含了 A 的所有成员,所以 B 的实例可以赋值给 A

b = a; 
// 错误,A 不包含 B 的所有成员,所以 A 的实例不能赋值给 B

但如果接口中存在可选成员,情况会有所不同:

interface C {
    x: number;
    y?: string;
}
interface D {
    x: number;
}

let c: C = { x: 1 };
let d: D = { x: 1 };

c = d; 
// 正确,D 包含了 C 的必选成员,可选成员可以不存在
d = c; 
// 错误,C 比 D 多了可选成员 y,所以 C 的实例不能赋值给 D

联合类型与交叉类型使用不当

联合类型和交叉类型是 TypeScript 中强大的类型工具,但如果使用不当,会带来很多问题。

联合类型取值判断不全面

联合类型表示一个值可以是多种类型之一。在使用联合类型时,必须全面考虑所有可能的类型。

function printValue(value: string | number) {
    if (typeof value ==='string') {
        console.log(value.length); 
    }
    // 这里没有处理 value 为 number 类型的情况
}

上述代码中,printValue 函数接受 string | number 联合类型的参数。但只处理了 string 类型的情况,当 valuenumber 类型时,就没有对应的处理逻辑。正确的做法是:

function printValue(value: string | number) {
    if (typeof value ==='string') {
        console.log(value.length); 
    } else {
        console.log(value.toFixed(2)); 
    }
}

交叉类型过度使用导致类型膨胀

交叉类型表示一个值同时具有多种类型的成员。然而,过度使用交叉类型可能会导致类型变得过于复杂,难以维护。

interface User {
    name: string;
    age: number;
}
interface Admin {
    role: string;
    permissions: string[];
}

// 交叉类型,一个用户同时是管理员
type AdminUser = User & Admin;

// 创建 AdminUser 实例
let adminUser: AdminUser = {
    name: 'John',
    age: 30,
    role: 'admin',
    permissions: ['read', 'write']
};

虽然上述代码看似合理,但如果有更多的接口参与交叉,例如再加入 Supervisor 接口,type SuperAdminUser = User & Admin & Supervisor;,那么 SuperAdminUser 的类型就会变得非常复杂,包含了来自三个接口的所有成员。此时可以考虑使用继承或组合的方式来简化类型结构。

泛型相关问题

泛型是 TypeScript 中提高代码复用性的重要特性,但在使用过程中也容易出现一些问题。

泛型类型参数未充分利用

有时开发者定义了泛型,但没有充分发挥其作用。

function identity<T>(arg: T): T {
    // 这里只是简单返回参数,没有对泛型类型进行额外操作
    return arg;
}

虽然上述 identity 函数定义了泛型 T,但仅仅是返回了参数,没有利用泛型进行更复杂的操作。可以改进为:

function identity<T>(arg: T[]): T[] {
    // 这里对泛型数组进行了反转操作
    return arg.reverse();
}

泛型约束不合理

泛型约束用于限制泛型类型的范围。如果约束不合理,可能达不到预期效果。

function printLength<T extends { length: number }>(arg: T) {
    console.log(arg.length); 
}

// 可以传入数组,因为数组有 length 属性
printLength([1, 2, 3]); 

// 也可以传入字符串,因为字符串有 length 属性
printLength('hello'); 

// 但是不能传入数字,因为数字没有 length 属性
// printLength(123); 

上述代码通过 T extends { length: number } 约束了泛型 T 必须有 length 属性。但如果约束过窄,例如改为 T extends string,那么就只能传入字符串,大大限制了函数的通用性。而如果约束过宽,例如 T extends {},则几乎没有起到约束作用。

类型断言问题

类型断言是告诉编译器“相信我,我知道自己在做什么”,但使用不当会破坏类型系统的安全性。

滥用类型断言

有些开发者为了快速解决类型错误,过度使用类型断言。

let value: any = 'hello';
// 这里滥用类型断言,将 any 类型断言为 number 类型,运行时会出错
let numValue = value as number; 
console.log(numValue.toFixed(2)); 

上述代码中,value 实际是字符串类型,却断言为 number 类型,这在运行时会抛出错误。正确的做法应该是先进行类型判断:

let value: any = 'hello';
if (typeof value === 'number') {
    let numValue = value as number; 
    console.log(numValue.toFixed(2)); 
}

不必要的类型断言

有时候,类型断言是不必要的,因为 TypeScript 可以自动推断类型。

let arr: number[] = [1, 2, 3];
// 这里不必要的类型断言,TypeScript 能自动推断 arr[0] 是 number 类型
let first = arr[0] as number; 

在上述代码中,arr 已经明确是 number 类型的数组,arr[0] 自然也是 number 类型,不需要额外的类型断言。

枚举类型问题

枚举类型用于定义一组命名的常量,但在使用中也存在一些需要注意的地方。

枚举值重复

枚举值如果重复,可能会导致难以发现的错误。

enum Colors {
    Red = 1,
    Green = 1, 
    // 这里 Green 和 Red 的值重复了
    Blue = 2
}

在上述枚举定义中,RedGreen 的值都为 1,这可能会在使用枚举时造成混淆。例如:

let color: Colors = Colors.Red;
if (color === Colors.Green) {
    // 由于值相同,这里条件会成立,可能不是预期的逻辑
    console.log('It is green'); 
}

未合理使用异构枚举

异构枚举是指枚举成员的值类型不一致。虽然 TypeScript 支持异构枚举,但通常不建议使用,因为会使代码逻辑变得复杂。

enum MixedEnum {
    First = 'one',
    Second = 2
}

在上述异构枚举中,First 是字符串类型,Second 是数字类型。这种不一致会让代码在使用 MixedEnum 时需要额外处理不同类型的值,增加了代码的复杂性。

类型别名与接口的混淆使用

类型别名和接口在很多方面功能相似,但也有一些区别,混淆使用可能会带来问题。

类型别名与接口的重复定义

有时开发者可能会对相同的类型结构同时使用类型别名和接口进行定义。

type UserType = {
    name: string;
    age: number;
};
interface UserInterface {
    name: string;
    age: number;
}

上述代码中,UserTypeUserInterface 定义了几乎相同的类型结构,这会造成代码冗余。在这种情况下,应该根据具体需求选择一种方式进行定义。

未正确使用类型别名和接口的特性

类型别名可以用于基本类型、联合类型、交叉类型等多种情况,而接口主要用于对象类型的定义。例如:

// 类型别名用于联合类型
type StringOrNumber = string | number;

// 接口不能直接用于联合类型
// interface StringOrNumberInterface {
//     string | number; 
//     // 报错:此处不允许使用表达式
// }

如果需要定义联合类型,应该使用类型别名。而在定义对象类型时,接口具有可扩展性等优势,例如:

interface User {
    name: string;
}
interface Admin extends User {
    role: string;
}

这里通过接口的继承,可以方便地扩展 User 接口。如果使用类型别名来定义对象类型,虽然也能实现类似功能,但语法相对复杂。

模块间类型不一致

在大型项目中,多个模块之间可能会出现类型不一致的问题,这会导致集成和维护困难。

不同模块对同一类型定义不同

假设项目中有两个模块,userModuleadminModule,都涉及用户相关的类型定义。

// userModule.ts
export type User = {
    name: string;
    age: number;
};

// adminModule.ts
export type User = {
    name: string;
    age: number;
    role: string;
};

在上述两个模块中,都定义了 User 类型,但 adminModule 中的 User 类型比 userModule 中的多了一个 role 属性。这会导致在不同模块间使用 User 类型时出现不一致的情况,例如在一个模块中创建的 User 实例,在另一个模块中使用时可能因为属性缺失或多余而报错。

为了避免这种情况,应该统一类型定义,例如将 User 类型定义在一个公共模块中,各个模块统一引用。

模块导入导出类型错误

在模块导入和导出类型时,也可能出现错误。

// utils.ts
export type Result = {
    success: boolean;
    data: any;
};

// main.ts
import { Result as MyResult } from './utils';

// 这里错误地将 MyResult 当作函数调用
let result = new MyResult(); 

在上述代码中,main.ts 导入 Result 类型并别名化为 MyResult,但之后却错误地将其当作函数调用。这可能是因为开发者混淆了类型和函数,应该确保在使用导入的类型时,使用方式与类型的定义相符。

类型与运行时逻辑不符

TypeScript 的类型检查是在编译时进行的,而运行时逻辑可能与类型定义不一致,这就会导致运行时错误。

类型转换在运行时失败

let value: any = '123';
let num = value as number; 
// 这里类型断言在编译时通过,但运行时 value 实际是字符串,无法转换为数字
console.log(num.toFixed(2)); 

虽然在编译时通过类型断言将 value 转换为 number 类型,但运行时 value 是字符串,无法正确转换为数字并调用 toFixed 方法。可以使用 parseIntparseFloat 等函数进行安全的类型转换:

let value: any = '123';
let num = parseInt(value); 
if (!isNaN(num)) {
    console.log(num.toFixed(2)); 
}

类型定义与实际数据结构不匹配

在处理对象或数组时,类型定义可能与实际的数据结构不匹配。

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

let points: Point[] = [
    { x: 1, y: 2 },
    { x: 3, z: 4 } 
    // 这里对象缺少 y 属性,多了 z 属性,与 Point 接口定义不匹配
];

在上述代码中,points 数组中的第二个对象与 Point 接口的定义不匹配,虽然在编译时可能不会报错(如果没有严格的类型检查配置),但在运行时访问 y 属性时会出现问题。

通过对以上这些常见类型问题的梳理和分析,开发者在进行 TypeScript 代码审查时能够更准确地发现问题,编写更健壮、可维护的代码。