TS 泛型:别再用 any 瞎混!这是类型世界的 “万能模具” + type/interface 终极辨析

114 阅读8分钟

你用 any 写 TS 的样子,像极了 “穿西装打赤脚”

为了让代码 “不报错”,随手把变量、函数返回值标成any—— 就像给鞋子套塑料袋,不管脚多大都能塞,但走路准崴脚。

看个真实例子:你想写个 “取数组第一个元素” 的函数,用 any 是这样的:

// 用any的版本:能跑,但等于没写TS
function getFirstElement(arr: any[]): any {
  return arr[0];
}

const numbers = [1, 2, 3];
const firstNumber = getFirstElement(numbers); 
// 这里firstNumber是any类型!你调用toFixed()都会有风险(万一返回字符串呢?)

const strs = ["a", "b"];
const firstStr = getFirstElement(strs);
// 同样,firstStr也是any,TS完全帮不了你检查类型

问题出在哪?any是 “摆烂式类型”—— 它让 TS 放弃所有类型检查,相当于你花了时间学 TS,却又退回到了 JS 的弱类型泥潭。我们要的是 “既能复用代码,又不丢类型安全” ,这时候泛型就该登场了。

泛型

如果说 JS 的函数是 “接收值参数,返回值结果”,那 TS 的泛型就是 “接收类型参数,返回新类型” —— 这是理解泛型的底层核心,没有比这更本质的解释了。

1. 泛型的 “函数类比”:把类型当 “参数” 传

先看个简单的 JS 函数:

// JS函数:接收“值参数”x,返回x*2(新值)
function double(x) {
  return x * 2;
}

再看泛型版本的getFirstElement

// TS泛型函数:接收“类型参数”T,返回T[]→T(新类型)
function getFirstElement<T>(arr: T[]): T | undefined {
  return arr.length > 0 ? arr[0] : undefined;
}

这里的<T>就是泛型的 “类型参数”—— 你可以把它理解成类型的 “占位符”  ,就像函数里的x一样。当你调用这个函数时:

  • 如果你传["hello", "world"](字符串数组),TS 会自动推导T=string,返回类型就是string | undefined
  • 如果你手动写getFirstElement<number>([1,2,3]),就是主动指定T=number,返回类型就是number | undefined

关键区别:用 any 时返回值是 “无类型”,用泛型时返回值类型和数组元素类型 “强绑定”—— 你再也不用担心调用firstNumber.toFixed()时,TS 告诉你 “any 类型可能没有 toFixed 方法” 了。

2. 约束(extends)与默认值,避免 “泛型滥用”

光有泛型还不够,有时候需要 “限制泛型的范围”(比如要求传入的对象必须有 id),这就需要 泛型约束;如果希望泛型有 “保底类型”,还能加 默认值

底层逻辑:extends 不是 “继承”,是 “类型范围缩小”

泛型约束里的 extends,本质是 “告诉 TS:T 必须是某个类型的子类型”,相当于缩小 T 的可选范围,避免传入无关类型:

// 定义“必须有 id”的约束类型
interface HasId {
  id: string | number;
}

// T extends HasId:限制 T 必须包含 id 属性
function getObjectId<T extends HasId>(obj: T): T["id"] {
  return obj.id;
}

// 正确:User 有 id,符合约束
const user = { id: 1, name: "张三" };
getObjectId(user); // 返回值类型:number

// 错误:dog 没有 id,不符合约束(TS 编译期报错)
const dog = { name: "旺财" };
getObjectId(dog); // 报错:缺少属性 "id"

这里的 T["id"] 是 索引访问类型,底层是 “通过属性名索引,获取 T 中该属性的具体类型”—— 比如 T=User 时,T["id"]=number,这是泛型与索引类型结合的基础用法。

泛型默认值:给 T 一个 “保底选项”

和函数默认参数类似,泛型也能加默认值 —— 没传类型参数时,T 会用默认类型: 、

// T 的默认值是 HasId:没传类型时,默认 T=HasId
function getDefaultId<T extends HasId = HasId>(obj: T): T["id"] {
  return obj.id;
}

// 没传类型参数,T 自动为 HasId
const defaultObj = { id: "2", age: 18 };
getDefaultId(defaultObj); // 正确

3. 泛型的底层价值:编译期 “类型安全”+ 运行时 “零成本”

很多人以为泛型是 “运行时生效的”,其实错了!泛型是纯编译期的特性——TS 在编译成 JS 时,会把所有泛型相关的代码(比如<T>)全部删掉,最终运行的 JS 里根本没有 “泛型” 这个东西。

这就实现了一个完美平衡:

  • 编译时:TS 通过泛型检查类型一致性(比如你不能给LinkedList<User> append 一个string),保证类型安全;
  • 运行时:没有额外的代码开销,性能和纯 JS 一样。

举个复杂点的例子 —— 用户给的 “泛型链表”:

// 泛型节点:T是value的类型占位符
class NodeItem<T> {
  value: T; //  value的类型由T决定
  next: NodeItem<T> | null = null; // next只能是同类型节点或null
  constructor(value: T) {
    this.value = value;
  }
}

// 泛型链表:整个链表只存T类型的数据
class LinkedList<T> {
  head: NodeItem<T> | null = null;
  append(value: T): void { // 只能append T类型的值
    const newNode = new NodeItem(value);
    // ...链表逻辑
  }
}

// 实际使用:指定T=User类型
interface User { id: number; name: string }
const userList = new LinkedList<User>();
userList.append({ id: 1, name: "张三" }); // 对的
userList.append("李四"); // 错!TS编译报错:类型"string"不是User

如果不用泛型,你要么写 N 个链表类(UserLinkedListStringLinkedList),要么用 any 让链表变成 “垃圾桶”—— 泛型就是用一套代码,搞定所有类型的复用,还不丢安全。

type 和 interface:别再分不清!

解决了 “类型复用”,接下来要解决 “自定义类型”——TS 给了两种方式:type(类型别名)和interface(接口)。很多人混用,但它们的底层设计目的完全不同。

先看相同点

最核心的相同点:都能描述对象结构,实现自定义类型。比如定义一个 “用户” 类型:

// interface版
interface UserDemo {
  name: string;
  age: number;
}

// type版
type UserType = {
  name: string;
  age: number;
};

// 用法完全一样
const u1: UserDemo = { name: "张三", age: 18 };
const u2: UserType = { name: "李四", age: 20 };

再看本质区别

1. 继承 / 组合的实现方式不同(底层逻辑差异)

  • interface:用extends(继承),本质是 “对象结构的继承”,和 JS 类的继承逻辑一致;
  • type:用&(交叉类型),本质是 “类型的合并”,把多个类型的属性拼到一起。
// interface继承
interface Person { name: string }
interface Employee extends Person { // 继承Person的name属性
  job: string;
}
const emp1: Employee = { name: "王五", job: "前端" }; // 正确

// type组合
type PersonType = { name: string }
type EmployeeType = PersonType & { // 合并PersonType和新属性
  job: string;
};
const emp2: EmployeeType = { name: "赵六", job: "后端" }; // 正确

2. 是否支持 “多次声明合并”(设计目的差异)

  • interface:支持!多次声明同一个接口,TS 会自动合并所有属性 —— 这是interface的核心设计之一,用来扩展现有类型(比如给 Window 加自定义属性);
  • type:不支持!type是 “类型别名”,别名不能重复定义,重复会报错。
// interface合并:合法
interface Animal { name: string }
interface Animal { age: number } // 第二次声明,自动合并
const dragon: Animal = { name: "奶龙", age: 100 }; // 正确:有name和age

// type重复定义:报错!
type AnimalType = { name: string }
type AnimalType = { age: number } // 错误:标识符"AnimalType"重复

3. 能描述的类型范围不同(底层能力差异)

  • type:能力更强,能描述所有类型—— 基础类型(如type Num = number)、联合类型(type ID = string | number)、元组(type Point = [number, number])、对象;
  • interface:能力有限,只能描述对象结构(包括对象、函数、类的结构),不能描述基础类型、联合类型、元组。
// type能做,interface不能做的事
type NumAlias = number; // 基础类型别名(interface不行)
type ID = string | number; // 联合类型(interface不行)
type Point = [number, number, string]; // 元组(interface不行)

// interface只能做对象结构
interface AddFn { // 描述函数结构(合法)
  (a: number, b: number): number;
}
interface Car { // 描述对象结构(合法)
  brand: string;
  speed: number;
}

4. 函数类型声明的语法差异

  • interface:用对象形式描述函数((参数):返回值);
  • type:更灵活,支持对象形式或箭头函数形式。
// interface声明函数类型
interface AddInterface {
  (a: number, b: number): number;
}

// type声明函数类型(两种写法)
type AddType1 = {
  (a: number, b: number): number;
};
type AddType2 = (a: number, b: number) => number; // 箭头函数形式,更简洁

// 用法一样
const add1: AddInterface = (a, b) => a + b;
const add2: AddType2 = (a, b) => a + b;

5. 基础类型别名的支持差异

  • type:可以给基础类型起别名(比如type Bool = boolean),方便复用;
  • interface:完全不支持,只能描述对象。
type Bool = boolean;
const isDone: Bool = false; // 正确

interface BoolInterface = boolean; // 错误:interface不能描述基础类型

什么时候用 type?什么时候用 interface?

场景推荐用原因
定义对象 / 函数 / 类的结构,且可能需要扩展(如合并、继承)interface支持 extends 和自动合并,符合对象导向的思维
定义基础类型别名、联合类型、元组type这些是 interface 做不到的
简单的对象结构,不需要扩展都行看团队规范,比如 React 组件 Props 常用 interface
需要组合多个类型(不是继承)type& 交叉类型比 extends 更灵活

TS 类型体系的 “底层逻辑”

  • 泛型:解决 “复用代码时不丢类型安全”,是类型世界的 “万能模具”(接收类型参数,返回新类型);
  • type/interface:解决 “自定义类型”,前者是 “万能类型别名”,后者是 “对象结构专属描述符”;
  • 核心原则:别用 any!用泛型保证复用 + 安全,用 type/interface 规范自定义类型 —— 这才是 TS 的正确打开方式。