TypeScript NoInfer<T>——精准控制泛型推断的工具类型

你写过这样的函数吗:泛型参数 T 出现在多个位置,结果调用时 T 被"污染"成了一个意外的联合类型,编译器无情地放过了一个明显的 bug------这就是 NoInfer<T> 要解决的问题。

一、引言

NoInfer<T> 是 TypeScript 5.4 在 2024 年 3 月引入的内置工具类型。它的实现非常"科幻":在 lib.es5.d.ts 里只有一行:

typescript 复制代码
type NoInfer<T> = intrinsic;

intrinsic 关键字的意思是"这个类型由编译器内部实现,没有用户态的等价物"。换句话说,它不是一个能用 TypeScript 自己写出来的类型,而是编译器留的一个钩子。

为什么需要这么个特殊东西?因为 TypeScript 的泛型推断有一个长期被诟病的"特性":当同一个泛型参数 T 出现在多个位置时,编译器会从所有位置收集候选类型,最终推出一个并集 。这在很多场景下并不是我们想要的------我们想让 T 只从某个"权威"位置推断出来,其他位置仅仅做校验。

本文会从一个让无数开发者翻车的真实场景入手,看清问题的本质,再深入 NoInfer 的工作原理、5 个实战场景、与 extends 约束/显式类型参数的对比,最后聊聊它在 Vue Composables、Pinia store、状态机这些日常场景里的应用。

前置知识 :本文假设你熟悉 TypeScript 的泛型、约束 (extends)、条件类型这几个概念。

版本要求:所有示例需要 TypeScript ≥ 5.4。低于 5.4 的项目可以用本文末尾给出的"老 hack"做替代,但并不推荐。

二、问题背景:被污染的泛型推断

2.1 红绿灯函数:当 TS 也"猪头"了一回

TypeScript 5.4 官方公告 用了一个红绿灯例子,把痛点讲得非常生动。我们改写一下:

typescript 复制代码
function createStreetLight<C extends string>(
  colors: C[],
  defaultColor?: C
) {
  return { colors, defaultColor };
}

createStreetLight(['red', 'yellow', 'green'], 'red'); // ✅ 期望中的写法

// 但这一行也能通过编译------这就不对了
createStreetLight(['red', 'yellow', 'green'], 'blue');

第二个调用应该报错才对------'blue' 根本不在 colors 列表里。然而 TypeScript 把 C 推断成了 'red' | 'yellow' | 'green' | 'blue',于是 'blue' 也被认为是合法的默认色。

问题出在 C 同时出现在 colorsdefaultColor 两个位置:编译器会从两个位置都收集候选,再做并集。defaultColor 不应该是"权威源",但它意外地参与了类型决策。

2.2 状态机:默认状态滑出可选范围

另一个经典案例是状态机定义。理想情况下,initial 必须是 states 中的一员:

typescript 复制代码
declare function createFSM<S extends string>(config: {
  initial: S;
  states: S[];
}): { state: S };

const fsm = createFSM({
  initial: 'not-allowed',         // 这是个 typo,应该报错
  states: ['open', 'closed'],
});
// fsm.state 的类型是 'not-allowed' | 'open' | 'closed'------bug 被放过了

Matt Pocock 的博客指出,这是 NoInfer 最经典的应用场景:多个位置都引用同一个泛型时,谁是"真理"? 在状态机里,states 才是"真理",initial 只是被约束的成员。

2.3 历史方案:多余的泛型参数

5.4 之前,业界有一个公认但丑陋的办法:给被约束的位置单独声明一个泛型参数,让它 extends 主泛型

typescript 复制代码
function createStreetLight<C extends string, D extends C>(
  colors: C[],
  defaultColor?: D
) {}

createStreetLight(['red', 'yellow', 'green'], 'blue');
//                                            ~~~~~~
// ❌ Argument of type '"blue"' is not assignable to parameter of type
//    '"red" | "yellow" | "green" | undefined'

这写法能用,但是有副作用:

  • 多了一个类型参数 D,签名复杂度上升
  • D 在函数体里通常用不上------只是为了拦截推断的"代码屎山"
  • 调用方有时被要求显式传入两个泛型,DX 变差

掘金上一篇分析直白地评价:函数签名中只用一次的泛型参数本身就是一种 code smell。NoInfer 就是为了消除这种 smell。

三、NoInfer 的工作原理

3.1 一行定义,全靠编译器

NoInfer<T> 在 lib 里的定义就是一行:

typescript 复制代码
// lib.es5.d.ts
type NoInfer<T> = intrinsic;

intrinsic 这个关键字告诉编译器:"碰到这个类型,走我专门的处理逻辑。" 具体逻辑可以拆成三步:

  1. 收集推断候选阶段 :当编译器扫描所有引用 T 的位置时,跳过被 NoInfer<T> 包裹的位置 ,不把它们的实参类型当作 T 的候选。
  2. 推断 T 阶段 :从剩下的位置(非 NoInfer 包裹的)确定 T 的最终类型。
  3. 校验阶段NoInfer<T> 等价于 T,被包裹位置的实参必须能赋值给已确定的 T

整个流程可以画成下图:

less 复制代码
┌──────────────────── 普通泛型 T ────────────────────┐
│                                                    │
│   位置 A: T  →┐                                    │
│              ├→ 收集候选 → 取并集 → T = A | B       │
│   位置 B: T  →┘                                    │
│                                                    │
└────────────────────────────────────────────────────┘

┌────────────────── NoInfer<T> ───────────────────┐
│                                                  │
│   位置 A: T          →┐                          │
│                       ├→ 仅从这里推 T              │
│   位置 B: NoInfer<T>  ✗ (跳过收集)                │
│                                                  │
│   推完后,再校验位置 B 的实参 ⊆ T                   │
│                                                  │
└──────────────────────────────────────────────────┘

3.2 实测:从 unknown 看清行为

最直接验证 NoInfer 行为的方式,是把它用在唯一的泛型位置上:

typescript 复制代码
function returnAsIs<T>(value: NoInfer<T>): T {
  return value;
}

const result = returnAsIs('hello');
// 类型:unknown

为什么是 unknown?因为:

  • T 出现的唯一位置被 NoInfer 包裹了
  • 候选收集阶段没有任何位置可以贡献 T
  • 编译器找不到 T 的"具体类型",只能给个 unknown

要让它工作,必须显式传:

typescript 复制代码
const result = returnAsIs<'hello'>('hello'); // 'hello'

这反向证明了 NoInfer<T> 的本质:它不是改变 T 的类型,而是改变 T 的"推断来源"

四、五个实战场景

4.1 默认值:避免被默认值反向污染

最常见也最简洁的场景:函数有"主参 + 默认参/回退参"两个位置都用 T

typescript 复制代码
function getOrDefault<T>(value: T | undefined, fallback: NoInfer<T>): T {
  return value ?? fallback;
}

declare function fetchScore(): number | undefined;

const score1 = getOrDefault(fetchScore(), 0);    // ✅ T = number
const score2 = getOrDefault(fetchScore(), '0');  // ❌ string 不可赋给 number

如果不加 NoInfer,第二行会通过------T 被推成 number | string,然后类型推断"开心地"接受了。

4.2 状态机:让 states 成为唯一真理

把开头的状态机改造一下:

typescript 复制代码
declare function createFSM<S extends string>(config: {
  initial: NoInfer<S>;
  states: S[];
}): { state: S };

createFSM({
  initial: 'not-allowed', // ❌ Type '"not-allowed"' is not assignable
                          //    to type '"open" | "closed"'
  states: ['open', 'closed'],
});

createFSM({
  initial: 'open',
  states: ['open', 'closed'],
}); // ✅

如果你设计的是一个反向逻辑------initial 才是真理、states 只是被约束------把 NoInfer 挪到 states 上即可:

typescript 复制代码
declare function createFSM2<S extends string>(config: {
  initial: S;
  states: NoInfer<S>[];
}): { state: S };

createFSM2({
  initial: 'open',
  states: ['closed'],
  // ❌ Type '"closed"' is not assignable to type '"open"'
});

这种"哪个参数说了算"的精确控制,是 NoInfer 最有价值的能力。

4.3 工具函数:类型化的 indexOf

Array.prototype.indexOf 的官方类型签名很宽松------它接受任意 searchElement: T。我们想要更严格的版本:搜索值必须在数组里。

typescript 复制代码
function indexOf<T>(arr: readonly T[], target: NoInfer<T>): number {
  return arr.indexOf(target);
}

const langs = ['ts', 'js', 'rust'] as const;

indexOf(langs, 'ts');  // ✅
indexOf(langs, 'go');  // ❌ Argument of type '"go"' is not assignable

Qiita 上的一篇文章展示了这个场景,并且对比了"用 <T extends string, U extends T> 双泛型"的旧写法------NoInfer 是它的简洁替代。

4.4 自定义 hooks:useReducer 的精确签名

写自定义 React/Vue hooks 时,经常遇到 stateaction 共享类型参数。NoInfer 让 reducer 必须严格按 state 类型来:

typescript 复制代码
function useReducer<S, A>(
  reducer: (state: S, action: A) => NoInfer<S>,
  initialState: S
): [S, (action: A) => void] {
  // 实现略
  return [initialState, () => {}];
}

// 调用方
const [state, dispatch] = useReducer(
  (s: number, a: { type: 'inc' | 'dec' }) => {
    return a.type === 'inc' ? s + 1 : s - 1;
    // 如果 reducer 返回了 string,会被立即拒绝
  },
  0
);

这里关键是 reducer 的返回值类型用了 NoInfer<S>S 已经从 initialState 推断出来了,reducer 的返回值必须配合,而不能反向影响 S

4.5 事件分发:payload 必须匹配 event name

typescript 复制代码
type Events = {
  click: { x: number; y: number };
  keydown: { key: string };
  scroll: { offset: number };
};

function emit<K extends keyof Events>(
  event: K,
  payload: NoInfer<Events[K]>
): void {
  // ...
}

emit('click', { x: 10, y: 20 });    // ✅
emit('keydown', { key: 'Enter' });  // ✅
emit('click', { key: 'Enter' });    // ❌ payload 不匹配 'click'

event 是"真理"------它决定 Kpayload 必须匹配,但不能影响 K 的推断。如果不加 NoInferemit('click', { key: 'Enter' }) 会让 K 退化为 'click' | 'keydown',类型推断失败但报错信息很差。

五、对比:NoInfer vs 约束 vs 显式类型参数

把三种"控制泛型推断"的方式拉成对比表:

方式 写法 调用 DX 是否引入额外参数 适用场景
双泛型 + 约束 <T, U extends T>(a: T[], b: U) TS 5.4 之前;需要保留 U 字面量
显式类型参数 <T>(a: T, b: T),调用时 f<T>(...) 简单场景,调用方愿意标注
NoInfer <T>(a: T, b: NoInfer<T>) TS 5.4+,绝大多数场景

举个完整对比:

typescript 复制代码
// 方案 A:约束写法(5.4 之前的标准做法)
function pickA<T extends string, U extends T>(arr: T[], target: U): T {
  return arr[arr.indexOf(target)];
}

// 方案 B:显式类型参数
function pickB<T extends string>(arr: T[], target: T): T {
  return arr[arr.indexOf(target)];
}
pickB<'a' | 'b'>(['a', 'b'], 'a'); // 调用方必须标注

// 方案 C:NoInfer
function pickC<T extends string>(arr: T[], target: NoInfer<T>): T {
  return arr[arr.indexOf(target)];
}
pickC(['a', 'b'], 'a'); // 自然推断

显然方案 C 在 5.4+ 是最优解。

六、Vue 与 Pinia 中的应用

6.1 Vue Composables:限制 setter 的类型

Vue 3 里,自定义 composable 经常返回 [state, setter]{ value, update } 这种结构。我们希望 setter 的入参严格匹配 state 的初始类型:

typescript 复制代码
import { ref, type Ref } from 'vue';

function useTypedRef<T>(initial: T) {
  const value = ref(initial) as Ref<T>;
  const set = (next: NoInfer<T>) => {
    value.value = next;
  };
  return { value, set };
}

const { value, set } = useTypedRef('hello');
set('world'); // ✅
set(42);      // ❌ number 不可赋给 string

如果不加 NoInfer,第二次调用 set(42) 时,TS 会试图把 T 拓宽成 string | number,错误信息也会变得含糊。

6.2 Vue 路由 meta 字段约束

写 Vue Router 配置时,我们希望子路由的 meta 必须是"父声明的字段子集":

typescript 复制代码
function defineRoutes<M extends Record<string, unknown>>(
  schema: { meta: M; routes: Array<{ path: string; meta: NoInfer<Partial<M>> }> }
) {
  return schema;
}

defineRoutes({
  meta: { requiresAuth: false, layout: 'default' },
  routes: [
    { path: '/login', meta: { requiresAuth: false } },                  // ✅
    { path: '/admin', meta: { requiresAuth: true, role: 'admin' } },    // ❌ role 未声明
  ],
});

M 由顶层 meta 决定,子路由的 meta 只是被它约束。

6.3 Pinia store:getter 返回值精确化

写 Pinia store 时,想要 getter 的返回值跟 state 字段强一致:

typescript 复制代码
function defineMyStore<S, G extends Record<string, (s: NoInfer<S>) => unknown>>(
  config: { state: () => S; getters: G }
) {
  return config;
}

const counterStore = defineMyStore({
  state: () => ({ count: 0 }),
  getters: {
    double(s) {
      // s 被推断为 { count: number },无歧义
      return s.count * 2;
    },
  },
});

如果没有 NoInferG 里 getter 的参数会反向影响 S 的推断,让类型变得很奇怪。这种模式在自己设计配置式 API 时非常实用。

七、坑与解:常见误用

7.1 全部位置都包成 NoInfer,结果是 unknown

typescript 复制代码
// ❌ 没有任何位置能推 T
function bad<T>(a: NoInfer<T>, b: NoInfer<T>): T {
  return a;
}

bad('hi', 'hi'); // T = unknown

NoInfer 必须至少留一个位置作为推断源。不然编译器无从下手。

7.2 用在返回值位置无效

typescript 复制代码
function returnTwice<T>(a: T): NoInfer<T>[] {
  return [a, a];
}

returnTwice('hi'); // T 仍然是 'hi',因为返回值不参与推断

返回值类型本来就不参与 T 的推断,把 NoInfer 套上去没有任何额外效果。NoInfer 只在参数位置有意义。

7.3 试图绕过显式类型参数

typescript 复制代码
function explicit<T>(value: NoInfer<T>): T {
  return value;
}

explicit('hello');           // ❌ 推不出来 T
explicit<string>('hello');   // ✅ 显式给

如果你的所有 T 都被 NoInfer 包了,调用方就被迫显式标注。这种 API 不友好,通常是设计错了。

7.4 与条件类型搭配时小心边界

typescript 复制代码
type Box<T> = { value: T };

function unwrap<T>(box: Box<T>, fallback: NoInfer<T>): T {
  return box.value ?? fallback;
}

const b: Box<number> = { value: 1 };
unwrap(b, 0);     // ✅
unwrap(b, '0');   // ❌

这种"复合泛型"的场景,NoInfer 表现良好。但如果你套了多层条件类型(T extends ... ? ... : ...),有时编译器的推断顺序会让 NoInfer 看起来"不工作"。遇到这种情况,先把签名简化、再加回复杂逻辑,逐步排查。

7.5 类型注解层面叠加 NoInfer 也无效

typescript 复制代码
const x: NoInfer<string> = 'hi';
// 等价于 const x: string = 'hi'

NoInfer 不是类型修饰符,只在泛型推断流程里有效。在普通类型注解上写它,没有任何额外作用。

八、5.4 之前的老 hack(仅作参考)

NoInfer 内置之前,社区流传过一个 hack:

typescript 复制代码
// 老 hack:让 T 被包在元组里,再立刻取出
type OldNoInfer<T> = [T][T extends any ? 0 : never];

function legacy<T>(a: T, b: OldNoInfer<T>): T {
  return a;
}

legacy('hi', 'hi'); // T 被推为 'hi'
legacy('hi', 1);    // ❌

这种写法的原理是:[T][...]T 放进数组,然后用条件类型在第二步"取出"------这个间接层让编译器的推断收集器跳过它。

但据 edge-cases.com 的分析,这个 hack 在复杂条件类型下偶有边角问题,且可读性差。5.4+ 直接用内置的 NoInfer 即可,不要再用 hack。

九、参考链接

十、小结

NoInfer<T> 解决的是一个非常具体但又非常普遍的问题:当泛型 T 在多个位置出现,谁是真理? 在 5.4 之前,我们要么靠双泛型 + 约束、要么靠显式类型参数、要么靠丑陋的 hack。5.4 之后,编译器原生提供了一个干净、声明式的答案。

它的内部实现是 intrinsic------意味着这是编译器层的特殊能力,无法用 TypeScript 自己写出来。但从使用者角度看,它就是一个普通的类型包装:告诉编译器"这个位置只校验,不参与推断"

写库代码、自定义 hooks、配置式 API 时,NoInfer 应该成为你的肌肉记忆。它能让 API 在保持简洁签名的同时,把"哪个参数说了算"这件事说得明明白白。

下次再设计一个泛型函数,发现自己在纠结"要不要加第二个泛型参数 D extends T"时,请先停一下,问自己:这个 D 在函数体里真的会被用到吗? 如果只是为了拦截推断------直接用 NoInfer 就够了。


本文属于「原生 API 冷知识」合集。如果你觉得有帮助,欢迎点赞、收藏、转发。我们下期见。


本内容由AI辅助生成

相关推荐
lichenyang4532 小时前
从 Web 容器开始,理解 ASCF 元服务开发
前端
用户059540174462 小时前
把待办应用从Electron换成Tauri,内存占用狂降90%,打包体积仅5MB
前端·css
假如让我当三天老蒯2 小时前
回归基本功!前端的解构赋值、扩展运算符、剩余参数
前端·面试
bonechips3 小时前
JS 数组指南:从内存原理到二维矩阵
前端·javascript
亿元程序员3 小时前
美术妹子让我给模型加个描边,我差点把Cocos卸了
前端
IT_陈寒3 小时前
React的useEffect依赖数组把我坑惨了,真相其实很简单
前端·人工智能·后端
徐小夕3 小时前
JitWord 3.0 正式发布,高精度Word异构解析+复杂组件兼容,打造web端协同Word编辑器
前端·vue.js·算法