TypeScript 泛型:深入理解与应用

TypeScript(简称TS)是一种由微软开发的静态类型检查的编程语言,它是JavaScript的一个超集,它添加了可选的静态类型系统和基于类的面向对象编程。泛型是TypeScript的一个核心特性,它允许你定义灵活的、可重用的组件。在这篇文章中,我们将深入探讨泛型的各个方面。

概念

泛型是一种编程概念,允许你创建可重用的代码,这些代码可以与多种类型的数据一起工作,而不是与特定类型的数据一起工作。在TypeScript中,泛型主要用于函数和类。

用法

泛型在TypeScript中通过尖括号<T>来实现,其中T是一个类型变量,可以是任何类型。你可以定义一个泛型函数、类或者接口。

泛型的写法

泛型主要用在四个场合:函数、接口、类和别名。

函数的泛型写法

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

let output = identity<string>("myString");

 那么对于变量形式定义的函数,泛型有下面两种写法。

// 写法一
let myId: <T>(arg: T) => T = id;

// 写法二
let myId: { <T>(arg: T): T } = id;

接口的泛型写法 

interface GenericIdentityFn<T> {
    (arg: T): T;
}

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

let myIdentity: GenericIdentityFn<number> = identity;

泛型接口还有第二种写法

interface Fn {
  <Type>(arg: Type): Type;
}

function id<Type>(arg: Type): Type {
  return arg;
}

let myId: Fn = id;

上面示例中,Fn的类型参数Type的具体类型,需要函数id在使用时提供。所以,最后一行的赋值语句不需要给出Type的具体类型。

此外,第二种写法还有一个差异之处。那就是它的类型参数定义在某个方法之中,其他属性和方法不能使用该类型参数。前面的第一种写法,类型参数定义在整个接口,接口内部的所有属性和方法都可以使用该类型参数。

类的泛型写法 

class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y; };

 泛型类的类型参数写在类名后面。

class Pair<K, V> {
  key: K;
  value: V;
}

 下面是继承泛型类的例子

class A<T> {
  value: T;
}

class B extends A<any> {}

上面示例中,类A有一个类型参数T,使用时必须给出T的类型,所以类B继承时要写成A<any>

泛型也可以用在类表达式。

const Container = class<T> {
  constructor(private readonly data: T) {}
};

const a = new Container<boolean>(true);
const b = new Container<number>(0);

上面示例中,新建实例时,需要同时给出类型参数T和类参数data的值。

下面是另一个例子。

class C<NumType> {
  value!: NumType;
  add!: (x: NumType, y: NumType) => NumType;
}

let foo = new C<number>();

foo.value = 0;
foo.add = function (x, y) {
  return x + y;
};

上面示例中,先新建类C的实例foo,然后再定义示例的value属性和add()方法。类的定义中,属性和方法后面的感叹号是非空断言,告诉 TypeScript 它们都是非空的,后面会赋值。

JavaScript 的类本质上是一个构造函数,因此也可以把泛型类写成构造函数。

type MyClass<T> = new (...args: any[]) => T;

// 或者
interface MyClass<T> {
  new (...args: any[]): T;
}

// 用法实例
function createInstance<T>(AnyClass: MyClass<T>, ...args: any[]): T {
  return new AnyClass(...args);
}

上面示例中,函数createInstance()的第一个参数AnyClass是构造函数(也可以是一个类),它的类型是MyClass<T>,这里的TcreateInstance()的类型参数,在该函数调用时再指定具体类型。

注意,泛型类描述的是类的实例,不包括静态属性和静态方法,因为这两者定义在类的本身。因此,它们不能引用类型参数。

class C<T> {
  static data: T; // 报错
  constructor(public value: T) {}
}

上面示例中,静态属性data引用了类型参数T,这是不可以的,因为类型参数只能用于实例属性和实例方法,所以报错了。

类型别名的泛型写法

type 命令定义的类型别名,也可以使用泛型。

type Nullable<T> = T | undefined | null;

上面示例中,Nullable<T>是一个泛型,只要传入一个类型,就可以得到这个类型与undefinednull的一个联合类型。

下面是另一个例子。

type Container<T> = { value: T };

const a: Container<number> = { value: 0 };
const b: Container<string> = { value: "b" };

下面是定义树形结构的例子。

type Tree<T> = {
  value: T;
  left: Tree<T> | null;
  right: Tree<T> | null;
};

上面示例中,类型别名Tree内部递归引用了Tree自身。

类型参数的默认值

类型参数可以设置默认值。使用时,如果没有给出类型参数的值,就会使用默认值。

function getFirst<T = string>(arr: T[]): T {
  return arr[0];
}

上面示例中,T = string表示类型参数的默认值是string。调用getFirst()时,如果不给出T的值,TypeScript 就认为T等于string

但是,因为 TypeScript 会从实际参数推断出T的值,从而覆盖掉默认值,所以下面的代码不会报错。

getFirst([1, 2, 3]); // 正确

上面示例中,实际参数是[1, 2, 3],TypeScript 推断 T 等于number,从而覆盖掉默认值string

类型参数的默认值,往往用在类中。

class Generic<T = string> {
  list: T[] = [];

  add(t: T) {
    this.list.push(t);
  }
}

上面示例中,类Generic有一个类型参数T,默认值为string。这意味着,属性list默认是一个字符串数组,方法add()的默认参数是一个字符串。

const g = new Generic();

g.add(4); // 报错
g.add("hello"); // 正确

上面示例中,新建Generic的实例g时,没有给出类型参数T的值,所以T就等于string。因此,向add()方法传入一个数值会报错,传入字符串就不会。

const g = new Generic<number>();

g.add(4); // 正确
g.add("hello"); // 报错

上面示例中,新建实例g时,给出了类型参数T的值是number,因此add()方法传入数值不会报错,传入字符串会报错。

一旦类型参数有默认值,就表示它是可选参数。如果有多个类型参数,可选参数必须在必选参数之后。

<T = boolean, U> // 错误

<T, U = boolean> // 正确

上面示例中,依次有两个类型参数TU。如果T是可选参数,U不是,就会报错。

数组的泛型表示

数组类型有一种表示方法是Array<T>。这就是泛型的写法,Array是 TypeScript 原生的一个类型接口,T是它的类型参数。声明数组时,需要提供T的值。

let arr: Array<number> = [1, 2, 3];

上面的示例中,Array<number>就是一个泛型,类型参数的值是number,表示该数组的全部成员都是数值。

同样的,如果数组成员都是字符串,那么类型就写成Array<string>。事实上,在 TypeScript 内部,数组类型的另一种写法number[]string[],只是Array<number>Array<string>的简写形式。

在 TypeScript 内部,Array是一个泛型接口,类型定义基本是下面的样子。

interface Array<Type> {
  length: number;

  pop(): Type | undefined;

  push(...items: Type[]): number;

  // ...
}

上面代码中,push()方法的参数item的类型是Type[],跟Array()的参数类型Type保持一致,表示只能添加同类型的成员。调用push()的时候,TypeScript 就会检查两者是否一致。

其他的 TypeScript 内部数据结构,比如MapSetPromise,其实也是泛型接口,完整的写法是Map<K, V>Set<T>Promise<T>

TypeScript 默认还提供一个ReadonlyArray<T>接口,表示只读数组。

function doStuff(values: ReadonlyArray<string>) {
  values.push("hello!"); // 报错
}

上面示例中,参数values的类型是ReadonlyArray<string>,表示不能修改这个数组,所以函数体内部新增数组成员就会报错。因此,如果不希望函数内部改动参数数组,就可以将该参数数组声明为ReadonlyArray<T>类型。

类型参数的约束条件

很多类型参数并不是无限制的,对于传入的类型存在约束条件。

function comp<Type>(a: Type, b: Type) {
  if (a.length >= b.length) {
    return a;
  }
  return b;
}

上面示例中,类型参数 Type 有一个隐藏的约束条件:它必须存在length属性。如果不满足这个条件,就会报错。

TypeScript 提供了一种语法,允许在类型参数上面写明约束条件,如果不满足条件,编译时就会报错。这样也可以有良好的语义,对类型参数进行说明。

function comp<T extends { length: number }>(a: T, b: T) {
  if (a.length >= b.length) {
    return a;
  }
  return b;
}

上面示例中,T extends { length: number }就是约束条件,表示类型参数 T 必须满足{ length: number },否则就会报错。

comp([1, 2], [1, 2, 3]); // 正确
comp("ab", "abc"); // 正确
comp(1, 2); // 报错

上面示例中,只要传入的参数类型不满足约束条件,就会报错。

类型参数的约束条件采用下面的形式。

<TypeParameter extends ConstraintType>

上面语法中,TypeParameter表示类型参数,extends是关键字,这是必须的,ConstraintType表示类型参数要满足的条件,即类型参数应该是ConstraintType的子类型。

类型参数可以同时设置约束条件和默认值,前提是默认值必须满足约束条件。

type Fn<A extends string, B extends string = "world"> = [A, B];

type Result = Fn<"hello">; // ["hello", "world"]

上面示例中,类型参数AB都有约束条件,并且B还有默认值。所以,调用Fn的时候,可以只给出A的值,不给出B的值。

另外,上例也可以看出,泛型本质上是一个类型函数,通过输入参数,获得结果,两者是一一对应关系。

如果有多个类型参数,一个类型参数的约束条件,可以引用其他参数。

<T, U extends T>
// 或者
<T extends U, U>

上面示例中,U的约束条件引用T,或者T的约束条件引用U,都是正确的。

但是,约束条件不能引用类型参数自身。

<T extends T>               // 报错
<T extends U, U extends T>  // 报错

上面示例中,T的约束条件不能是T自身。同理,多个类型参数也不能互相约束(即T的约束条件是UU的约束条件是T),因为互相约束就意味着约束条件就是类型参数自身。

使用注意点

泛型有一些使用注意点。

(1)尽量少用泛型。

泛型虽然灵活,但是会加大代码的复杂性,使其变得难读难写。一般来说,只要使用了泛型,类型声明通常都不太易读,容易写得很复杂。因此,可以不用泛型就不要用。

(2)类型参数越少越好。

多一个类型参数,多一道替换步骤,加大复杂性。因此,类型参数越少越好。

function filter<T, Fn extends (arg: T) => boolean>(arr: T[], func: Fn): T[] {
  return arr.filter(func);
}

上面示例有两个类型参数,但是第二个类型参数Fn是不必要的,完全可以直接写在函数参数的类型声明里面。

function filter<T>(arr: T[], func: (arg: T) => boolean): T[] {
  return arr.filter(func);
}

上面示例中,类型参数简化成了一个,效果与前一个示例是一样的。

(3)类型参数需要出现两次。

如果类型参数在定义后只出现一次,那么很可能是不必要的。

function greet<Str extends string>(s: Str) {
  console.log("Hello, " + s);
}

 上面示例中,类型参数Str只在函数声明中出现一次(除了它的定义部分),这往往表明这个类型参数是不必要。

function greet(s: string) {
  console.log("Hello, " + s);
}

上面示例把前面的类型参数省略了,效果与前一个示例是一样的。

也就是说,只有当类型参数用到两次或两次以上,才是泛型的适用场合。

(4)泛型可以嵌套。

类型参数可以是另一个泛型。

type OrNull<Type> = Type | null;

type OneOrMany<Type> = Type | Type[];

type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>;

上面示例中,最后一行的泛型OrNull的类型参数,就是另一个泛型OneOrMany

实际案例

在实际开发中,泛型常用于数据结构和算法的实现,例如:

  1. 数组工具函数:创建处理任何类型数组的函数,如findmapfilter等。
  2. 缓存实现:使用泛型来创建一个可以存储任何类型键值对的缓存系统。
  3. 数据库模型:在ORM(对象关系映射)中,泛型用于创建可以映射到不同数据库表的模型类。

总结

泛型是TypeScript的强大特性,它提供了一种定义和使用通用数据结构的方式。通过泛型,你可以编写更灵活、更安全、更可读的代码。理解并掌握泛型,对于任何TypeScript开发者来说都是至关重要的。

希望这篇文章能帮助你更好地理解TypeScript泛型的使用和应用。如果你有任何问题或想要更深入的讨论,欢迎在评论区交流。

(下一篇:在 TypeScript 中,泛型和接口之间有什么联系和区别?)

  • 13
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值