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

TypeScript类型守卫与类型断言最佳实践

2021-07-164.5k 阅读

一、TypeScript 类型守卫基础

在 TypeScript 中,类型守卫是一种运行时检查机制,它允许我们在代码执行过程中确定一个值的类型。这对于处理联合类型(union types)非常有用,因为联合类型表示一个值可以是多种类型中的一种。

1.1 typeof 类型守卫

typeof 操作符在 JavaScript 中用于返回一个值的类型字符串,在 TypeScript 中,它也可以作为类型守卫。例如,考虑以下代码:

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

在上述代码中,if (typeof value ==='string') 就是一个类型守卫。它在运行时检查 value 的类型是否为 string。如果是,TypeScript 就知道在这个代码块内 valuestring 类型,因此可以调用 toUpperCase 方法。否则,value 被认为是 number 类型,就可以调用 toFixed 方法。

1.2 instanceof 类型守卫

instanceof 操作符用于检查一个对象是否是某个类的实例。在 TypeScript 中,它同样可以作为类型守卫。假设我们有如下类和函数:

class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
}
class Dog extends Animal {
    bark() {
        console.log('Woof!');
    }
}
function handleAnimal(animal: Animal | Dog) {
    if (animal instanceof Dog) {
        animal.bark();
    } else {
        console.log(`This animal is ${animal.name}`);
    }
}
const myDog = new Dog('Buddy');
const myAnimal = new Animal('Generic Animal');
handleAnimal(myDog); 
handleAnimal(myAnimal); 

这里,if (animal instanceof Dog) 是类型守卫。它检查 animal 是否是 Dog 类的实例。如果是,在该代码块内 animal 就被认为是 Dog 类型,可以调用 bark 方法。否则,animal 被当作 Animal 类型。

1.3 in 类型守卫

in 操作符可以用来检查一个对象是否包含某个属性。在 TypeScript 中,这也能作为类型守卫。例如:

interface Bird {
    fly: () => void;
    beakType: string;
}
interface Fish {
    swim: () => void;
    finType: string;
}
function handleCreature(creature: Bird | Fish) {
    if ('fly' in creature) {
        creature.fly();
        console.log(`This bird has a ${creature.beakType} beak`);
    } else {
        creature.swim();
        console.log(`This fish has ${creature.finType} fins`);
    }
}
const myBird: Bird = {
    fly: () => console.log('Flying'),
    beakType: 'pointed'
};
const myFish: Fish = {
    swim: () => console.log('Swimming'),
    finType:'streamlined'
};
handleCreature(myBird); 
handleCreature(myFish); 

在上述代码里,if ('fly' in creature) 是类型守卫。如果 creature 包含 fly 属性,TypeScript 就知道 creatureBird 类型,否则就是 Fish 类型。

二、自定义类型守卫

虽然 typeofinstanceofin 是很有用的类型守卫,但有时候我们需要更定制化的类型检查。这时候就可以使用自定义类型守卫。

2.1 类型谓词的定义

自定义类型守卫是一个返回类型谓词的函数。类型谓词的语法是 parameterName is Type,其中 parameterName 是函数参数名,Type 是要检查的类型。例如:

function isString(value: any): value is string {
    return typeof value ==='string';
}
function printStringValue(value: any) {
    if (isString(value)) {
        console.log(value.toUpperCase());
    }
}
printStringValue('test'); 
printStringValue(123); 

isString 函数中,value is string 就是类型谓词。它告诉 TypeScript,如果 isString 函数返回 true,那么传入的 value 就是 string 类型。

2.2 复杂对象结构的自定义类型守卫

假设我们有一个复杂的对象结构,并且希望通过自定义类型守卫来确定其具体类型。例如,我们有两种不同结构的用户对象:

interface Admin {
    role: 'admin';
    permissions: string[];
}
interface User {
    role: 'user';
    preferences: { [key: string]: string };
}
function isAdmin(user: Admin | User): user is Admin {
    return user.role === 'admin';
}
function handleUser(user: Admin | User) {
    if (isAdmin(user)) {
        console.log(`Admin has permissions: ${user.permissions.join(', ')}`);
    } else {
        console.log(`User has preferences: ${JSON.stringify(user.preferences)}`);
    }
}
const adminUser: Admin = {
    role: 'admin',
    permissions: ['create', 'delete']
};
const regularUser: User = {
    role: 'user',
    preferences: { theme: 'light' }
};
handleUser(adminUser); 
handleUser(regularUser); 

这里的 isAdmin 函数是一个自定义类型守卫。通过检查 role 属性,它能确定传入的 user 对象是 Admin 类型还是 User 类型,从而使我们可以在不同的代码块中对不同类型的对象进行正确的操作。

三、类型断言

类型断言是一种告诉编译器“相信我,这个值就是这种类型”的方式。它并不真的检查值的类型,而是让我们在代码中手动指定类型。

3.1 语法

TypeScript 中有两种类型断言语法:

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

例如:

let someValue: any = 'this is a string';
let strLength1: number = (<string>someValue).length;
let strLength2: number = (someValue as string).length;

这两种语法的作用是一样的,在大多数情况下可以互换使用。不过,在使用 JSX 时,必须使用 as 语法,因为尖括号语法会被误认为是 JSX 标签。

3.2 类型断言的使用场景

  1. 绕过类型检查:当我们明确知道某个值的类型,但 TypeScript 无法自动推断时,可以使用类型断言。例如,从 document.getElementById 获取元素时,TypeScript 返回的类型是 HTMLElement | null,但如果我们确定元素存在,可以使用类型断言:
const myButton = document.getElementById('myButton') as HTMLButtonElement;
myButton.click();
  1. 函数返回值类型断言:有时候函数的返回值类型难以被 TypeScript 正确推断,我们可以使用类型断言。例如:
function getRandomValue(): any {
    return Math.random() > 0.5? 'text' : 42;
}
let result: string = getRandomValue() as string;
console.log(result.length); 

这里我们通过类型断言将 getRandomValue 的返回值指定为 string 类型,以便可以调用 length 属性。不过要注意,这种情况下如果实际返回值不是 string 类型,运行时会出错。

四、类型守卫与类型断言的比较

  1. 运行时行为
    • 类型守卫:类型守卫是在运行时进行的检查,它会实际验证值的类型。例如 typeofinstanceof 等类型守卫会根据值的实际情况来确定类型。
    • 类型断言:类型断言不进行运行时检查,它只是告诉编译器按照指定的类型来处理值。因此,如果断言的类型不正确,在运行时可能会导致错误。
  2. 使用场景
    • 类型守卫:适用于在不知道值的确切类型,但可以通过某些条件在运行时确定类型的情况。比如处理联合类型时,通过类型守卫可以在不同分支中处理不同类型的值。
    • 类型断言:适用于我们明确知道值的类型,只是 TypeScript 无法自动推断出来的情况。例如从 DOM 获取元素,或者处理一些库的返回值类型不明确的情况。
  3. 代码安全性
    • 类型守卫:相对更安全,因为它在运行时进行检查,只有满足条件才会进入相应的代码块,减少运行时错误的可能性。
    • 类型断言:如果使用不当,可能会导致运行时错误,因为编译器只是按照我们断言的类型来处理,不会实际检查值的类型。

五、最佳实践

  1. 优先使用类型守卫:在大多数情况下,应该优先使用类型守卫来处理联合类型。类型守卫通过运行时检查确保代码的安全性,能有效避免类型错误。例如,在处理 string | number 联合类型时,使用 typeof 类型守卫:
function add(a: string | number, b: string | number) {
    if (typeof a === 'number' && typeof b === 'number') {
        return a + b;
    } else if (typeof a ==='string' && typeof b ==='string') {
        return a + b;
    }
    throw new Error('Unsupported types for addition');
}
console.log(add(1, 2)); 
console.log(add('a', 'b')); 
  1. 谨慎使用类型断言:只有在确实知道值的类型,并且 TypeScript 无法正确推断时才使用类型断言。同时,要确保断言的类型是正确的,避免运行时错误。例如,在使用第三方库时,如果库的类型定义不完善,可以使用类型断言,但要仔细检查。
// 假设第三方库返回一个不明确类型的值
const thirdPartyValue = getThirdPartyValue();
const myValue = thirdPartyValue as MyExpectedType;
// 这里要确保 thirdPartyValue 实际是 MyExpectedType 类型
  1. 结合使用:在一些复杂的场景下,可以结合类型守卫和类型断言。例如,先使用类型守卫缩小类型范围,然后在特定分支中使用类型断言进一步明确类型。
function processValue(value: string | number | null) {
    if (value!== null) {
        if (typeof value ==='string') {
            const strValue = value as string;
            console.log(strValue.toUpperCase());
        } else {
            const numValue = value as number;
            console.log(numValue.toFixed(2));
        }
    }
}
processValue('test'); 
processValue(42); 
processValue(null); 

这里先通过 value!== null 类型守卫排除 null 值,然后在 stringnumber 的分支中使用类型断言,虽然在这种情况下类型断言不是必需的,但在更复杂的类型结构中,这样做可以使代码更清晰。 4. 自定义类型守卫的设计:当设计自定义类型守卫时,要确保其逻辑清晰、准确。尽量使类型守卫的条件简单明了,易于理解和维护。例如:

interface Circle {
    shape: 'circle';
    radius: number;
}
interface Square {
    shape:'square';
    sideLength: number;
}
function isCircle(shape: Circle | Square): shape is Circle {
    return shape.shape === 'circle';
}
function calculateArea(shape: Circle | Square) {
    if (isCircle(shape)) {
        return Math.PI * shape.radius * shape.radius;
    } else {
        return shape.sideLength * shape.sideLength;
    }
}
const myCircle: Circle = { shape: 'circle', radius: 5 };
const mySquare: Square = { shape:'square', sideLength: 4 };
console.log(calculateArea(myCircle)); 
console.log(calculateArea(mySquare)); 

在这个例子中,isCircle 自定义类型守卫逻辑简单,能准确判断对象是否为 Circle 类型,从而使 calculateArea 函数可以正确计算不同形状的面积。 5. 避免过度使用类型断言:过度使用类型断言会破坏 TypeScript 的类型安全机制,使代码更难维护和调试。尽量让 TypeScript 自动推断类型,只有在必要时才使用类型断言。例如,不要仅仅为了让代码通过编译而随意使用类型断言,而应该思考如何通过更好的类型定义或类型守卫来解决类型问题。 6. 文档化类型断言:如果使用了类型断言,最好在代码中添加注释说明为什么要进行这样的断言。这有助于其他开发者理解代码,也方便自己日后维护。例如:

// 因为第三方库的返回值类型定义不完善,实际返回值是 MyType
const result = thirdPartyFunction() as MyType; 
  1. 使用类型别名和接口来辅助类型守卫和断言:通过定义清晰的类型别名和接口,可以使类型守卫和类型断言更易于理解和维护。例如:
type Fruit = 'apple' | 'banana' | 'cherry';
function isApple(fruit: Fruit): fruit is 'apple' {
    return fruit === 'apple';
}
function handleFruit(fruit: Fruit) {
    if (isApple(fruit)) {
        console.log('It\'s an apple');
    } else {
        console.log('It\'s not an apple');
    }
}
handleFruit('apple'); 
handleFruit('banana'); 

这里通过类型别名 Fruit 定义了可能的水果类型,isApple 类型守卫基于此类型别名进行判断,使代码逻辑更清晰。

通过遵循这些最佳实践,可以在 TypeScript 项目中更有效地使用类型守卫和类型断言,提高代码的质量、可读性和可维护性。无论是处理复杂的联合类型,还是应对类型推断不明确的情况,正确运用类型守卫和类型断言都能让我们的代码更加健壮和可靠。在实际开发中,不断实践和总结经验,根据具体场景选择最合适的方式来处理类型,是成为熟练 TypeScript 开发者的关键。同时,要注意保持代码的简洁性和可理解性,避免过度复杂的类型操作,以免给后续的维护带来困难。