前端开发··2 阅读·预计 8 分钟

TypeScript 泛型 Hook 的型变陷阱:从协变返回到逆变回调的 React 类型工程

引言

抽一个泛型 Hook 看似只是「给函数加个尖括号」,但 TypeScript 的型变规则会在这里显形:返回值想协变,回调参数却要逆变。写反了方向,类型检查器要么放过本不该放过的调用,要么把合理的调用拒之门外。本文用可复现的正反例,讲清泛型 Hook 里最容易被误解的三处型变关系。

一、泛型 Hook 的本质:把推断点交给调用方

普通 Hook 类型固定,泛型 Hook 则是把类型参数「延迟」到调用处才确定。反例是把类型参数写在无用的位置,让推断失效。

// 反例:泛型参数未参与输入,推断退化成 unknown
function useFetcher<T>(url: string) {
  const [data, setData] = useState<T>(null as T);
  // T 无法从 url 推断,调用处不得不手动指定,甚至得到 any
  return data;
}

// 调用处被迫补类型,且约束为零
const user = useFetcher<User>('/api/user');
// 正例:让泛型锚定在真实输入上,交由推断
function useFetcher<T>(url: string, parser: (raw: string) => T) {
  const [data, setData] = useState<T | null>(null);
  // T 由 parser 的返回值推断,类型流是单向且明确的
  return data;
}

const user = useFetcher('/api/user', JSON.parse); // T 自动推断为 any? 不,见下文

泛型类型参数要「锚定在一个能推断它的形参上」,否则它只会污染调用处,逼着开发者写冗余的类型标注。

二、协变返回:能返回子类型的地方别收窄成父类型

返回值的型变方向是协变(covariant):Promise<User> 可以赋值给 Promise<Person>,因为 User 是 Person 的子类型。泛型 Hook 里,把返回类型过度收窄会丢掉推理能力。

// 反例:显式把返回收窄成联合类型,调用方失去精确类型
function useStoredValue(key: string): string | number {
  // 实现里其实知道该返回 string,但签名砍掉了精度
  return readStorage(key);
}
const v = useStoredValue('name'); // 类型是 string|number,处处要收窄
// 正例:让返回类型随泛型参数流动,保持协变
function useStoredValue<T>(key: string, fallback: T): T {
  const raw = readStorage(key);
  return (raw === null ? fallback : raw) as T;
}
const v = useStoredValue('name', 'anonymous'); // 推断为 string

返回类型别写死,让它从泛型参数协变出去,调用处才能得到最精确的类型,省去后续手写断言。

三、逆变回调:回调参数要用「父类型」接收

与返回相反,回调函数参数是逆变(contravariant)位置。最常见的错误是给回调参数标注成具体子类型,导致「本应接受更宽输入的回调」被类型系统拒绝。

type Handler<T> = (value: T) => void;

// 反例:把回调参数标成具体类型,破坏逆变
function useSubscription(
  fn: (user: { id: number; name: string }) => void
) { /* ... */ }

// 调用方想传一个更通用的处理函数,却被拒绝
declare const handle: (x: { name: string }) => void;
useSubscription(handle); // 类型错误:参数过窄,回调无法接收完整 user

这里 handle 只需要一个 name,本可以处理任何带 name 的对象;但 useSubscription 要求回调必须接收完整 {id,name},把「宽输入」的回调硬塞进「窄输入」的要求,逆变被打断。

// 正例:回调参数用泛型 + 约束,让它保持逆变方向
type Handler<T> = (value: T) => void;

function useSubscription<T extends { name: string }>(
  fn: Handler<T>
) { /* 内部以 T 的完整信息调用 */ }

useSubscription<{ id: number; name: string }>(handle); // 通过

让泛型把「回调接受的类型」与「内部传入的类型」对齐,逆变方向就自然成立。真正的难点是记住 strictFunctionTypes 只对方法签名之外的回调位置生效。

四、一个反直观案例:useCallback 的依赖与型变叠加

型变不是孤立存在的,它和 useCallback 的引用稳定性叠加时,会放大类型错误。

// 反例:内联箭头让回调类型每次推断都不同,破坏 memo
const cb = useCallback((x: { id: number }) => x.id, []);

function subscribe(fn: (x: { name: string }) => void) { /* ... */ }
subscribe(cb); // 报错:cb 要求 id,但 subscribe 只保证传 name

内联函数让 TypeScript 把 cb 推断成一个「恰好接收 {id}」的函数,显式参数标注被隐式忽略了逆变方向。

// 正例:显式声明回调类型,让参数的父类型显性化
type Subscriber = (x: { name: string }) => void;
const cb = useCallback<Subscriber>((x) => x.name, []);
subscribe(cb); // 通过,且引用稳定

把回调的类型显式声明为接口,泛型推断和逆变就都回到可控轨道。

结语

泛型 Hook 的类型工程,核心是一句话:返回值顺着泛型协变出去,回调参数逆着泛型收进来。协变让你拿到精确的返回类型,逆变让你的回调能接受恰如其分的输入。把泛型锚定在能推断它的形参上,避免退化成 unknown 或被迫手写标注。这三条一旦内化,泛型 Hook 就不再是「拼尖括号」,而是类型流在两个方向上都受控的设计。

0 评论

评论区

登录 后参与评论