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

深入理解TypeScript泛型的类型参数

2021-03-023.7k 阅读

什么是TypeScript泛型中的类型参数

在TypeScript中,泛型是一种强大的工具,它允许我们在定义函数、接口、类时不指定具体的类型,而是使用类型参数来表示。类型参数就像是一个占位符,在使用这些函数、接口或类时才会被具体的类型所替换。

例如,我们定义一个简单的函数,它接受一个参数并返回这个参数:

function identity(arg: any): any {
    return arg;
}

这个函数使用any类型,虽然能实现功能,但它失去了类型检查的优势。如果我们使用泛型和类型参数,可以这样改写:

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

这里的<T>就是类型参数,T是类型变量。在调用这个函数时,我们可以指定T具体的类型:

let result = identity<number>(5);

这样,TypeScript就能在编译时进行类型检查,确保类型安全。

类型参数的命名规范

单个大写字母命名

在TypeScript社区中,常用单个大写字母来命名类型参数,例如TUKV等。T通常表示“Type”,U可用于表示第二个类型,K常用于表示对象的键(Key),V常用于表示对象的值(Value)。

function swap<T, U>(a: T, b: U): [U, T] {
    return [b, a];
}

let result = swap<string, number>("hello", 42);

描述性命名

当类型参数代表特定的概念或具有复杂的含义时,使用描述性命名会使代码更易读。比如,当定义一个处理用户数据的函数,类型参数可以命名为UserType

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

function getUserData<UserType extends User>(user: UserType): UserType {
    return user;
}

let user: User = { name: "John", age: 30 };
let userData = getUserData(user);

这种命名方式在大型项目中,尤其是多人协作的代码库中,能显著提高代码的可维护性。

类型参数的约束

简单类型约束

有时候,我们希望类型参数满足一定的条件,这就需要对类型参数进行约束。例如,我们希望传入的类型具有.length属性,可以这样定义约束:

interface Lengthwise {
    length: number;
}

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

let result = loggingIdentity("hello");
let numResult = loggingIdentity(123); // 报错,number类型没有length属性

这里通过T extends Lengthwise,确保了T类型必须包含length属性,否则会在编译时报错。

多重类型约束

类型参数可以有多个约束。假设我们有两个接口AB,我们希望类型参数同时满足这两个接口的约束:

interface A {
    aProp: string;
}

interface B {
    bProp: number;
}

function combinedFunction<T extends A & B>(arg: T): T {
    console.log(arg.aProp);
    console.log(arg.bProp);
    return arg;
}

let obj: A & B = { aProp: "test", bProp: 123 };
let combinedResult = combinedFunction(obj);

通过T extends A & B,确保了T类型同时具有AB接口定义的属性。

约束与类型参数的继承

当在类或接口中使用泛型类型参数时,也可以对其进行约束,并且这种约束会影响到子类或实现接口的类。

interface BaseType {
    id: number;
}

class BaseClass<T extends BaseType> {
    constructor(public data: T) {}
}

class DerivedClass extends BaseClass<{ id: number; name: string }> {
    printName() {
        console.log(this.data.name);
    }
}

let derived = new DerivedClass({ id: 1, name: "example" });
derived.printName();

在这个例子中,BaseClass的类型参数T受到BaseType接口的约束。DerivedClass继承自BaseClass并指定了更具体的类型,这个类型必须满足BaseType的约束。

类型参数的默认值

基本类型默认值

TypeScript允许为类型参数设置默认值。当调用函数、实例化类或实现接口时,如果没有显式指定类型参数,就会使用默认值。例如,我们定义一个函数,它可以处理数组,默认情况下处理number类型的数组:

function createArray<T = number>(length: number, value: T): T[] {
    let result: T[] = [];
    for (let i = 0; i < length; i++) {
        result.push(value);
    }
    return result;
}

let numberArray = createArray(3, 10);
let stringArray = createArray<string>(5, "hello");

这里T的默认值是number,如果不指定T,就会创建一个number类型的数组。如果指定了Tstring,则会创建string类型的数组。

复杂类型默认值

类型参数的默认值也可以是复杂类型,比如接口或类。假设我们有一个接口DefaultUser,并为一个泛型函数设置了以这个接口为默认值的类型参数:

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

function getUser<T = DefaultUser>(): T {
    return { name: "default", age: 18 } as T;
}

let defaultUser = getUser();
let customUser: { name: string; age: number; email: string } = getUser<{
    name: string;
    age: number;
    email: string;
}>();

在这个例子中,getUser函数如果不指定类型参数,会返回DefaultUser类型的对象。如果指定了更复杂的类型,会根据指定类型返回相应的对象。

类型参数在函数重载中的应用

函数重载与类型参数

函数重载允许我们为同一个函数定义多个不同参数列表和返回类型的签名。当函数使用泛型类型参数时,结合函数重载可以实现更灵活和类型安全的功能。

function processValue<T>(arg: T): T;
function processValue<T>(arg: T[]): T[];
function processValue<T>(arg: T | T[]): T | T[] {
    if (Array.isArray(arg)) {
        return arg.map((item) => item);
    } else {
        return arg;
    }
}

let singleValue = processValue(10);
let arrayValue = processValue([1, 2, 3]);

这里我们定义了两个函数重载签名,一个接受单个类型T的参数并返回T,另一个接受T类型的数组并返回T类型的数组。实际实现函数根据传入参数是否为数组来进行不同的处理。

类型参数与重载解析

在调用重载函数时,TypeScript会根据传入的参数来解析应该使用哪个重载签名。对于泛型类型参数,解析过程同样遵循这个规则。

function printValue<T>(arg: T): void;
function printValue<T>(arg: T[]): void;
function printValue<T>(arg: T | T[]): void {
    if (Array.isArray(arg)) {
        arg.forEach((item) => console.log(item));
    } else {
        console.log(arg);
    }
}

printValue(123);
printValue([456]);

在这个例子中,当传入单个值时,TypeScript会选择第一个重载签名;当传入数组时,会选择第二个重载签名。

类型参数在接口中的应用

泛型接口定义

我们可以定义泛型接口,使接口中的属性或方法的类型可以动态指定。例如,定义一个简单的泛型接口来表示一个包含值的对象:

interface ValueContainer<T> {
    value: T;
}

let numberContainer: ValueContainer<number> = { value: 42 };
let stringContainer: ValueContainer<string> = { value: "hello" };

这里的ValueContainer接口使用了类型参数T,使得value属性的类型可以根据实际使用情况进行指定。

泛型接口的继承

泛型接口也可以继承其他接口,并且可以传递或约束类型参数。假设我们有一个基础的泛型接口BaseInterface,然后定义一个继承自它的DerivedInterface

interface BaseInterface<T> {
    baseProp: T;
}

interface DerivedInterface<T extends string> extends BaseInterface<T> {
    derivedProp: T;
}

let derived: DerivedInterface<string> = { baseProp: "base", derivedProp: "derived" };
let wrongDerived: DerivedInterface<number> = { baseProp: 123, derivedProp: 456 }; // 报错,number类型不满足T extends string的约束

在这个例子中,DerivedInterface继承自BaseInterface,并且对类型参数T进行了约束,要求T必须是string类型。

类型参数在类中的应用

泛型类定义

定义泛型类可以使类的属性、方法的类型根据实例化时的类型参数进行动态调整。例如,定义一个简单的栈类:

class Stack<T> {
    private items: T[] = [];

    push(item: T) {
        this.items.push(item);
    }

    pop(): T | undefined {
        return this.items.pop();
    }
}

let numberStack = new Stack<number>();
numberStack.push(1);
let poppedNumber = numberStack.pop();

let stringStack = new Stack<string>();
stringStack.push("hello");
let poppedString = stringStack.pop();

这里的Stack类使用了类型参数T,使得栈可以存储不同类型的数据,并且在编译时能保证类型安全。

泛型类的继承与实现

当泛型类继承自其他类或实现接口时,需要处理类型参数的传递和约束。假设我们有一个基础类BaseClass和一个接口MyInterface,然后定义一个泛型类DerivedClass

class BaseClass {
    baseMethod() {
        console.log("Base method");
    }
}

interface MyInterface<T> {
    myMethod(arg: T): T;
}

class DerivedClass<T> extends BaseClass implements MyInterface<T> {
    myMethod(arg: T): T {
        return arg;
    }
}

let derived = new DerivedClass<number>();
derived.baseMethod();
let result = derived.myMethod(10);

在这个例子中,DerivedClass继承自BaseClass并实现了MyInterface,同时保留了类型参数T,使得myMethod方法可以处理不同类型的参数。

类型参数与条件类型

条件类型基础

条件类型是TypeScript中一种强大的类型运算,它根据条件来选择不同的类型。类型参数在条件类型中起着关键作用。例如,定义一个简单的条件类型,根据类型是否为string来返回不同的类型:

type StringOrNumber<T> = T extends string ? string : number;

let type1: StringOrNumber<string> = "hello";
let type2: StringOrNumber<number> = 42;

这里StringOrNumber类型使用了类型参数T,通过T extends string条件判断,如果Tstring类型,就返回string,否则返回number

条件类型与泛型约束

结合泛型约束和条件类型,可以实现更复杂的类型推导。例如,我们希望根据传入的类型是否为数组来返回不同的处理结果:

type ArrayOrSingle<T> = T extends any[] ? T[0] : T;

function process<T>(arg: T): ArrayOrSingle<T> {
    if (Array.isArray(arg)) {
        return arg[0] as ArrayOrSingle<T>;
    } else {
        return arg;
    }
}

let singleResult = process(10);
let arrayResult = process([1, 2, 3]);

在这个例子中,ArrayOrSingle类型根据T是否为数组类型进行类型推导。process函数根据传入参数的类型,返回相应的处理结果。

分布式条件类型

分布式条件类型是条件类型的一种特殊形式,当条件类型的类型参数是裸类型参数(没有被其他类型包裹)时,会自动对联合类型进行分发。例如:

type ToString<T> = T extends any ? string : never;

let result: ToString<string | number> = "hello" | "42";

这里ToString类型参数T是裸类型参数,当传入string | number联合类型时,会自动对联合类型中的每个类型进行条件判断,最终返回string | string,合并后就是string类型。

类型参数与映射类型

映射类型基础

映射类型允许我们基于现有类型创建新类型,通过对类型的属性进行映射操作。类型参数在映射类型中用于动态指定要操作的类型。例如,定义一个简单的映射类型,将对象的所有属性变为只读:

type ReadonlyType<T> = {
    readonly [P in keyof T]: T[P];
};

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

let readonlyUser: ReadonlyType<User> = { name: "John", age: 30 };
readonlyUser.name = "Jane"; // 报错,只读属性不能被重新赋值

这里ReadonlyType类型使用类型参数T,通过[P in keyof T]遍历T类型的所有属性,并将它们变为只读。

映射类型与条件类型结合

结合映射类型和条件类型,可以实现更复杂的类型转换。例如,我们希望将对象中所有string类型的属性变为可选:

type MakeStringPropsOptional<T> = {
    [P in keyof T]: T[P] extends string ? T[P] | undefined : T[P];
};

interface Data {
    name: string;
    age: number;
    address: string;
}

let newData: MakeStringPropsOptional<Data> = { age: 30 };

在这个例子中,MakeStringPropsOptional类型通过条件类型判断T类型中每个属性是否为string,如果是,则将其变为可选,否则保持不变。

类型参数的高级应用场景

在函数式编程中的应用

在函数式编程中,泛型类型参数常用于实现高阶函数。例如,实现一个map函数,它接受一个函数和一个数组,并对数组中的每个元素应用这个函数:

function map<T, U>(fn: (arg: T) => U, arr: T[]): U[] {
    return arr.map(fn);
}

let numbers = [1, 2, 3];
let squaredNumbers = map((num) => num * num, numbers);

这里map函数使用了两个类型参数TUT表示数组元素的类型,U表示应用函数后返回值的类型。

在库开发中的应用

在开发TypeScript库时,泛型类型参数可以提供高度的灵活性和可复用性。例如,开发一个数据存储库,它可以处理不同类型的数据:

class DataStore<T> {
    private data: T[] = [];

    add(item: T) {
        this.data.push(item);
    }

    getAll(): T[] {
        return this.data;
    }
}

let userStore = new DataStore<{ name: string; age: number }>();
userStore.add({ name: "John", age: 30 });
let users = userStore.getAll();

这个DataStore类使用类型参数T,使得它可以存储不同类型的数据,满足各种业务需求。

在框架开发中的应用

在框架开发中,泛型类型参数可以用于定义通用的组件和服务。例如,在一个前端框架中,定义一个通用的列表组件:

interface ListProps<T> {
    items: T[];
    renderItem: (item: T) => JSX.Element;
}

function List<T>(props: ListProps<T>): JSX.Element {
    return (
        <ul>
            {props.items.map((item) => (
                <li>{props.renderItem(item)}</li>
            ))}
        </ul>
    );
}

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

function userRenderer(user: User): JSX.Element {
    return (
        <div>
            <span>{user.name}</span>
            <span>{user.age}</span>
        </div>
    );
}

let userListProps: ListProps<User> = {
    items: [
        { name: "John", age: 30 },
        { name: "Jane", age: 25 }
    ],
    renderItem: userRenderer
};

let userList = List(userListProps);

这里List组件使用类型参数T,使得它可以处理不同类型数据的列表渲染,通过renderItem函数来定制每个列表项的渲染方式。

通过深入理解TypeScript泛型的类型参数,我们可以编写出更加灵活、可复用且类型安全的代码,无论是在小型项目还是大型的企业级应用中,都能极大地提高开发效率和代码质量。