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

TypeScript中的宽进严出策略

2021-07-035.6k 阅读

一、TypeScript 类型系统基础

在深入探讨 “宽进严出” 策略之前,我们先来回顾一下 TypeScript 的类型系统基础。TypeScript 是 JavaScript 的超集,它为 JavaScript 引入了静态类型检查。这意味着在代码编译阶段,TypeScript 编译器能够根据我们定义的类型,检查代码中可能存在的类型错误。

  1. 基本类型 TypeScript 支持一系列基本类型,例如:
let num: number = 42;
let str: string = "Hello, TypeScript";
let bool: boolean = true;
let nullValue: null = null;
let undefinedValue: undefined = undefined;

这里,我们使用冒号 : 来指定变量的类型。在上述例子中,num 被指定为 number 类型,strstring 类型,以此类推。

  1. 类型推断 TypeScript 具有强大的类型推断能力。当我们声明变量并同时初始化时,TypeScript 可以自动推断出变量的类型。
let inferredNum = 10; // TypeScript 推断 inferredNum 为 number 类型
let inferredStr = "world"; // TypeScript 推断 inferredStr 为 string 类型

在这种情况下,即使我们没有显式地指定类型,TypeScript 编译器也能理解变量的类型,并在后续代码中基于此进行类型检查。

  1. 对象类型 我们可以定义对象的形状(shape),即对象所包含的属性及其类型。
let user: { name: string; age: number } = { name: "Alice", age: 30 };

这里定义了一个 user 对象,它必须包含 name 属性,类型为 string,以及 age 属性,类型为 number。如果我们尝试给 user 对象添加不符合这个形状的属性,TypeScript 编译器会报错。

// 报错:对象字面量只能指定已知属性,但是这里有个 'gender' 不在类型 '{ name: string; age: number; }' 中
user.gender = "female"; 

二、理解 “宽进严出” 策略

“宽进严出” 是 TypeScript 在类型处理上的一种策略,它在保证代码灵活性的同时,又能确保类型安全。

  1. “宽进” 的含义 “宽进” 指的是在某些情况下,TypeScript 允许相对宽松的类型赋值。这主要体现在类型兼容性方面。例如,子类型可以赋值给父类型。
class Animal {}
class Dog extends Animal {}

let animal: Animal;
let dog: Dog = new Dog();
animal = dog; // 允许,因为 Dog 是 Animal 的子类型

这里,Dog 类继承自 Animal 类,所以 Dog 类型的实例可以赋值给 Animal 类型的变量。这就是一种 “宽进” 的体现,它允许我们以一种较为宽松的方式进行类型赋值,使得代码在处理对象继承关系时更加灵活。

再比如,在函数参数的类型匹配上,也存在 “宽进” 的情况。

function greet(person: { name: string }) {
    console.log(`Hello, ${person.name}`);
}

let user = { name: "Bob", age: 25 };
greet(user); // 允许,因为 { name: string, age: number } 类型包含了 { name: string } 类型所要求的属性

这里,greet 函数期望一个具有 name 属性且类型为 string 的对象作为参数。虽然 user 对象除了 name 属性外还有 age 属性,但这并不影响它被传递给 greet 函数,因为它满足了 greet 函数对参数类型的基本要求。

  1. “严出” 的含义 “严出” 则强调在类型使用的过程中,TypeScript 会严格检查类型的一致性。一旦类型被确定,在后续的操作中必须严格遵循该类型的定义。
let num: number = 10;
// 报错:不能将类型 'string' 分配给类型 'number'
num = "ten"; 

这里,我们最初将 num 定义为 number 类型,之后如果尝试将一个 string 类型的值赋给它,TypeScript 编译器会抛出错误,严格要求类型的一致性。

在函数返回值类型方面,同样遵循 “严出” 原则。

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

// 报错:不能将类型 'string' 分配给类型 'number'
let result: number = add(1, 2).toString(); 

add 函数定义了返回值类型为 number,但如果我们尝试将其返回值当作 string 类型来使用(这里通过调用 toString 方法),并赋值给一个 number 类型的变量,TypeScript 编译器就会报错。

三、“宽进严出” 在函数参数和返回值中的应用

  1. 函数参数的 “宽进” 在函数调用时,TypeScript 对函数参数的类型检查相对宽松,只要实际参数的类型能够满足函数定义中对参数类型的要求即可。
function printName(person: { firstName: string; lastName?: string }) {
    if (person.lastName) {
        console.log(`${person.firstName} ${person.lastName}`);
    } else {
        console.log(person.firstName);
    }
}

let employee = { firstName: "John", lastName: "Doe", department: "Engineering" };
printName(employee); // 允许,因为 employee 对象满足 printName 函数对参数的类型要求

printName 函数要求参数是一个具有 firstName 属性(类型为 string)且 lastName 属性可选(类型为 string)的对象。employee 对象虽然包含了额外的 department 属性,但这并不影响它作为参数传递给 printName 函数,体现了 “宽进” 的特性。

  1. 函数返回值的 “严出” 函数返回值的类型在 TypeScript 中受到严格检查。一旦函数定义了返回值类型,实际返回的值必须与该类型完全匹配(或者是其子类型,在存在继承关系的情况下)。
function getFullName(person: { firstName: string; lastName: string }): string {
    return `${person.firstName} ${person.lastName}`;
}

let user = { firstName: "Jane", lastName: "Smith" };
let fullName: string = getFullName(user);
// 尝试将返回值当作 number 类型使用会报错
let wrongType: number = getFullName(user); 

getFullName 函数定义了返回值类型为 string,所以我们必须将其返回值赋值给 string 类型的变量。如果尝试将其赋值给 number 类型的变量,TypeScript 编译器会报错,这体现了 “严出” 的原则。

四、“宽进严出” 与类型兼容性

  1. 赋值兼容性 在 TypeScript 中,赋值兼容性是 “宽进严出” 策略的重要体现。对于对象类型,赋值兼容性遵循结构子类型(structural subtyping)原则。
interface Rectangle {
    width: number;
    height: number;
}

interface Square extends Rectangle {
    sideLength: number;
}

let rect: Rectangle;
let square: Square = { width: 10, height: 10, sideLength: 10 };
rect = square; // 允许,因为 Square 类型的结构包含了 Rectangle 类型的所有属性

这里,Square 接口继承自 Rectangle 接口,并且 Square 类型的对象包含了 Rectangle 类型所要求的所有属性,所以 Square 类型的变量可以赋值给 Rectangle 类型的变量,这是 “宽进” 的体现。

然而,如果反过来进行赋值,就会出现错误。

// 报错:类型 'Rectangle' 缺少属性'sideLength',但类型 'Square' 中需要该属性
square = rect; 

这体现了 “严出”,因为 rect 对象不具备 square 所要求的 sideLength 属性,不能将 Rectangle 类型赋值给 Square 类型。

  1. 函数参数和返回值类型兼容性 在函数类型兼容性方面,同样遵循 “宽进严出” 的策略。对于函数参数类型,是逆变(contravariant)的;对于函数返回值类型,是协变(covariant)的。
// 定义一个函数类型
type AnimalProcessor = (animal: Animal) => void;
type DogProcessor = (dog: Dog) => void;

let animalProcessor: AnimalProcessor;
let dogProcessor: DogProcessor = (dog) => { console.log(`Processing dog: ${dog.name}`); };

animalProcessor = dogProcessor; // 允许,因为函数参数类型是逆变的

这里,DogAnimal 的子类型。在函数类型赋值中,DogProcessor 类型的函数可以赋值给 AnimalProcessor 类型的变量,因为函数参数类型是逆变的,即子类型的函数参数可以赋值给父类型的函数参数,这是 “宽进” 的体现。

对于返回值类型的协变:

type AnimalFactory = () => Animal;
type DogFactory = () => Dog;

let animalFactory: AnimalFactory;
let dogFactory: DogFactory = () => new Dog();

animalFactory = dogFactory; // 允许,因为函数返回值类型是协变的

这里,DogAnimal 的子类型,DogFactory 类型的函数可以赋值给 AnimalFactory 类型的变量,因为函数返回值类型是协变的,即子类型的函数返回值可以赋值给父类型的函数返回值,这同样体现了 “宽进”。

五、“宽进严出” 策略下的类型断言

  1. 类型断言的作用 类型断言(type assertion)是一种告诉编译器 “我知道自己在做什么” 的方式,它允许我们手动指定一个值的类型。在 “宽进严出” 的策略下,类型断言有时是必要的,因为编译器并不总是能够准确推断出我们想要的类型。
let someValue: any = "this is a string";
// 使用类型断言将 someValue 断言为 number 类型
let strLength: number = (someValue as string).length; 

这里,someValue 的初始类型为 any,我们知道它实际上是一个 string 类型,所以使用类型断言 (someValue as string) 将其断言为 string 类型,然后才能安全地访问 length 属性。

  1. 类型断言的两种语法 TypeScript 提供了两种类型断言的语法:as 语法和尖括号 <> 语法。
// as 语法
let value1: any = "hello";
let length1: number = (value1 as string).length;

// 尖括号语法(在.jsx 文件中不能使用)
let value2: any = "world";
let length2: number = (<string>value2).length; 

这两种语法的作用是相同的,选择使用哪种语法主要取决于个人习惯或项目的编码规范。但需要注意的是,在 .jsx 文件中,只能使用 as 语法,因为尖括号 <> 语法在 jsx 中会被解析为 JSX 标签。

  1. 滥用类型断言的风险 虽然类型断言在某些情况下很有用,但滥用类型断言可能会破坏 TypeScript 的类型安全机制。
let wrongValue: any = 10;
// 错误的类型断言,将 number 类型断言为 string 类型
let wrongLength: number = (wrongValue as string).length; 

这里,我们将一个实际为 number 类型的值错误地断言为 string 类型,在运行时会导致 length 属性访问错误,因为 number 类型没有 length 属性。所以,在使用类型断言时,我们必须确保断言的类型是正确的,否则会绕过 TypeScript 的类型检查,带来潜在的运行时错误。

六、“宽进严出” 与泛型

  1. 泛型基础 泛型(generics)是 TypeScript 中一个强大的特性,它允许我们在定义函数、类或接口时,不指定具体的类型,而是使用一个类型参数,在使用时再确定具体的类型。
function identity<T>(arg: T): T {
    return arg;
}

let result1 = identity<number>(42); // result1 的类型为 number
let result2 = identity<string>("hello"); // result2 的类型为 string

这里,identity 函数使用了泛型类型参数 T,它可以表示任何类型。在调用 identity 函数时,我们通过 <number><string> 来指定 T 的具体类型,函数会根据我们指定的类型返回相应类型的值。

  1. 泛型与 “宽进严出” 泛型在一定程度上也体现了 “宽进严出” 的策略。在定义泛型函数或类时,类型参数是相对宽松的,我们可以使用任何类型来实例化泛型。
class Box<T> {
    private value: T;
    constructor(value: T) {
        this.value = value;
    }
    getValue(): T {
        return this.value;
    }
}

let numberBox = new Box<number>(10);
let stringBox = new Box<string>("test");

这里,Box 类使用了泛型类型参数 T,我们可以用 number 类型实例化 Box 类来创建 numberBox,也可以用 string 类型实例化来创建 stringBox,这体现了 “宽进”。

而在使用泛型实例时,TypeScript 会严格按照实例化时指定的类型进行类型检查。

// 报错:不能将类型 'number' 分配给类型'string'
let wrongAssignment: string = numberBox.getValue(); 

这里,numberBox 实例化时使用了 number 类型,所以 getValue 方法返回的是 number 类型的值,不能将其赋值给 string 类型的变量,体现了 “严出”。

  1. 泛型约束 为了在 “宽进” 的同时保证一定的类型安全性,我们可以对泛型类型参数添加约束。
interface Lengthwise {
    length: number;
}

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

printLength("hello"); // 允许,string 类型实现了 Lengthwise 接口
printLength([1, 2, 3]); // 允许,数组类型实现了 Lengthwise 接口
// 报错:类型 'number' 不满足约束 'Lengthwise'
printLength(10); 

这里,我们定义了一个 Lengthwise 接口,要求类型必须包含 length 属性。然后在 printLength 函数中,通过 T extends Lengthwise 对泛型类型参数 T 进行约束,只有实现了 Lengthwise 接口的类型才能作为 T 的实例化类型,这样既保持了一定的灵活性(“宽进”),又保证了类型安全(“严出”)。

七、“宽进严出” 在实际项目中的应用场景

  1. 数据获取与处理 在前端开发中,经常需要从后端获取数据。后端返回的数据格式可能并不完全符合我们在前端定义的严格类型。例如,后端可能返回一个包含额外字段的 JSON 对象。
interface User {
    name: string;
    age: number;
}

async function fetchUser(): Promise<User> {
    const response = await fetch('/api/user');
    const data = await response.json();
    // 假设后端返回的数据包含了一个额外的 'email' 字段
    return data as User; 
}

let user: User = await fetchUser();

这里,我们使用类型断言将后端返回的数据断言为 User 类型,因为我们知道数据的主要部分符合 User 接口的定义,尽管可能存在额外字段,这体现了 “宽进”。而在后续使用 user 变量时,TypeScript 会严格按照 User 接口的定义进行类型检查,体现了 “严出”。

  1. 组件库开发 在开发组件库时,为了提高组件的通用性,常常会使用泛型和 “宽进严出” 的策略。
import React from'react';

interface Props<T> {
    items: T[];
    renderItem: (item: T) => React.ReactNode;
}

function List<T>(props: Props<T>) {
    return (
        <ul>
            {props.items.map((item) => (
                <li key={item.toString()}>{props.renderItem(item)}</li>
            ))}
        </ul>
    );
}

interface User {
    name: string;
    age: number;
}

function UserListItem({ name, age }: User) {
    return `${name} - ${age}`;
}

let users: User[] = [
    { name: "Alice", age: 25 },
    { name: "Bob", age: 30 }
];

<List<User> items={users} renderItem={UserListItem} />

在这个 List 组件中,使用了泛型 T 来表示列表项的类型。这样,List 组件可以用于渲染不同类型的列表,体现了 “宽进”。而在具体使用 List 组件时,例如渲染 User 类型的列表,TypeScript 会严格检查 itemsrenderItem 的类型,确保类型安全,体现了 “严出”。

  1. 第三方库集成 当集成第三方库时,由于第三方库的类型定义可能不完全准确或与我们项目的类型体系不完全匹配,我们可能需要运用 “宽进严出” 策略。
// 假设引入了一个第三方库,其类型定义不完全准确
import * as thirdPartyLib from 'third - party - lib';

// 使用类型断言来适配第三方库的类型
let result: number = (thirdPartyLib.someFunction() as number); 

这里,我们通过类型断言将第三方库函数的返回值适配为我们期望的 number 类型,以使其能够融入我们的项目代码中,这是 “宽进” 的体现。而在后续使用 result 变量时,TypeScript 会按照 number 类型进行严格检查,体现了 “严出”。

八、“宽进严出” 策略的优势与挑战

  1. 优势

    • 提高代码灵活性:“宽进” 部分允许我们在一定程度上灵活地处理类型,例如在对象继承和函数参数匹配方面,使得代码能够更好地适应不同的场景,减少不必要的类型定义和转换。
    • 保证类型安全:“严出” 部分确保在类型使用的关键环节,如变量赋值、函数返回值等,严格遵循类型定义,防止类型错误的发生,提高代码的稳定性和可维护性。
    • 便于集成和扩展:在与第三方库集成或进行项目扩展时,“宽进严出” 策略可以帮助我们更好地处理类型不一致的问题,同时保证项目自身的类型安全。
  2. 挑战

    • 类型推断的复杂性:由于 “宽进严出” 策略下存在类型兼容性和类型推断等机制,有时会导致类型推断变得复杂,特别是在涉及泛型、函数重载等复杂场景时,开发人员可能需要花费更多的精力来理解和调试类型相关的问题。
    • 潜在的运行时错误:如果在 “宽进” 过程中,例如使用类型断言时不小心出错,绕过了 TypeScript 的类型检查,可能会导致潜在的运行时错误,这种错误在编译阶段无法被发现,增加了调试的难度。
    • 代码可读性问题:过多地使用 “宽进” 特性,如频繁的类型断言或复杂的类型兼容性处理,可能会降低代码的可读性,使得其他开发人员难以理解代码的类型意图。

九、如何更好地遵循 “宽进严出” 策略

  1. 合理使用类型断言 在使用类型断言时,要确保有足够的依据。尽量避免在不确定类型的情况下进行断言,而是通过类型判断或更好的类型定义来解决类型问题。
let value: any = getSomeValue();
if (typeof value ==='string') {
    let length: number = value.length;
} else {
    // 处理其他情况
}

这里,通过 typeof 判断来确定 value 的类型,而不是直接使用类型断言,这样可以提高代码的安全性。

  1. 精确的类型定义 在项目中,尽量提供精确的类型定义,特别是在函数参数、返回值和对象接口方面。这样可以减少不必要的类型兼容性问题,使得 “宽进严出” 策略更加清晰和可控。
interface LoginRequest {
    username: string;
    password: string;
}

function login(request: LoginRequest): Promise<boolean> {
    // 登录逻辑
}

通过精确的 LoginRequest 接口定义,在调用 login 函数时,TypeScript 可以更好地进行类型检查,遵循 “宽进严出” 策略。

  1. 学习和理解类型系统 开发人员需要深入学习和理解 TypeScript 的类型系统,包括类型兼容性、类型推断、泛型等核心概念。只有这样,才能在项目中正确地运用 “宽进严出” 策略,避免因对类型系统不熟悉而导致的类型错误。

  2. 代码审查 在团队开发中,进行代码审查时要特别关注类型相关的部分。检查是否存在不合理的类型断言、是否有精确的类型定义以及是否遵循了 “宽进严出” 的策略,及时发现并纠正潜在的类型问题。

总之,“宽进严出” 是 TypeScript 类型系统的重要策略,它在灵活性和类型安全之间找到了一个平衡点。通过合理运用这一策略,开发人员能够编写出更加健壮、可维护的代码。但同时,我们也要注意其带来的挑战,通过精确的类型定义、合理使用类型断言等方式,更好地遵循这一策略,提升项目的质量。