你写的是 TypeScript 还是 anyScript ?

0 阅读7分钟

很多人上 TypeScript 的第一周很兴奋:终于有智能提示了;
第二周开始皱眉:这里报红、那里不兼容;
第三周熟练了——遇事不决,祭出 any,红线消失,世界安静;
那,你写的是 TypeScript 还是 anyScript ?

一、先认清敌人:any 不是「偷懒快捷键」,而是「关掉的安检门」

any 是类型系统里的 Top Type(顶级类型)——兼容一切,也被一切兼容;在它身上赋值、访问、链式调用,类型推导与检查相当于被关掉。你写的是 .ts,体验却是「带高亮的 JavaScript」。

所以 anyScript 不是梗,而是真实状态:编译器还在跑,但飞机起飞前的预检已经失效了。线上出了问题,监控日志里还是熟悉的 Cannot read properties of undefined

二、any 从哪来?不止你手写的那一行

除了显式写 any,还有几条暗线:

  1. 隐式 anylet foo;、未标注的函数参数,在旧配置里会变成 any;而开启 strictnoImplicitAny 会倒逼你改正
  2. 「先 any 再说」:类型不兼容 => any;结构太深 => any;对接口不熟 => any
  3. 库与边界:例如 Ant Design 的 Form 默认 Values = any,你若不再包一层类型,表单值就会在项目里一路「泄洪」

那我们该怎么做呢?

  • 红线报错时先想断言,太复杂就先断言成最小的可用形状;
  • 语义是「未知」时首选用 unknown 而不是 any

三、工程层:把 any 关进笼子里

1. 先打开 strict 全家桶

我们可以在工程中的 tsconfig.json 里打开 "strict": true,这是正确基底——相当于告诉团队:默认要类型,例外要理由

2. 用 ESLint 盯死「裸 any」

在 TS 场景下,Lint 常常不是让你少写代码,而是多写一点「对的」代码——例如禁止随意的 any,要求更明确的类型。

团队可逐步启用 @typescript-eslint/no-explicit-any 等规则,配合 CI,避免 any 在 PR 里扩散。

3. 分清「类型体操」和「业务类型」

首先要肯定一点,类型体操不应作为 TS 水平的唯一度量;真正有用的是能稳定交付、能重构、能少踩运行时坑的类型习惯。

体操练的是模式匹配、递归、infer——用在 API 返回值与泛型组件上很香;但若为了炫技忽略列表页、表单页的基础建模,就是捡芝麻丢西瓜,这又让我想起了那句话:技术是为业务服务的。


四、代码层:少写 any,多写「有约束的替代方案」

1. 表单:用泛型钉死 Form 的值类型

比如在我们团队某项目中,onFinish 用了 values: any

const onFinish = (values: any) => {
  const { username, password } = values || {}
  run(username, password)
  // ...
}

改进思路:为登录表单声明一个 LoginFormValues 接口,再交给 Form / onFinishusernamepassword就都有提示和检查了:

interface LoginFormValues {
  username: string
  password: string
}

const onFinish = (values: LoginFormValues) => {
  const { username, password } = values
  run(username, password)
  // ...
}

这里没有用到高难度体操,只是对象类型 + 一处标注,但是性价比确很高。

2. 列表:别让 useState([]) 把数组元素推断成 never 再被迫 any

List.tsx 里列表初始是 [],遍历时用 (q: any) 兜底:

const [list, setList] = useState([])

// ...

{list.length > 0 &&
  list.map((q: any) => {
    const { _id } = q
    return <QuestionCard key={_id} {...q} />
  })}

改进思路:在 services/questioncomponents/QuestionCard 已有类型时,抽一个 QuestionCardItem(或复用接口),然后:

const [list, setList] = useState<QuestionCardItem[]>([])
// list.map((q) => ...) 不再需要 any

「类型编程」:当「返回值结构和参数有关、需要算出来的类型」时,才上 infer、条件类型;列表元素这种稳定结构,用接口一次到位往往更合适,即能动态生成类型才必须上编程;否则用接口往往更干净。

3. 真未知时用 unknown,收窄后再用

对外部 JSON、eval、第三方脚本,标注 unknown,在分支里用 typeof / in / 自定义 type guard 收窄,而不是 any 一把梭。
any 是「我无处不在」;unknown 是「我暂时未知,但你会在确定之后再动我」。

类型守卫(Type Predicate):把「运行时判断」和「类型收窄」绑在一起,避免在分支里反复 as

function isQuestionRecord(x: unknown): x is { _id: string; title: string } {
  return (
    typeof x === 'object' &&
    x !== null &&
    '_id' in x &&
    typeof (x as { _id: unknown })._id === 'string'
  )
}

function consume(raw: unknown) {
  if (!isQuestionRecord(raw)) return
  // 此处 raw 已收窄,无需 any
  console.log(raw.title)
}

五、常用 TypeScript 用法锦囊:少写 any 的「工具箱」

我下面列出了业务里高频、性价比极高的用法,熟悉后很多场景不必再用 any 糊弄过去啦。

1. 字面量类型与 as const:把「具体值」保留在类型里

默认推导常常是「宽类型」(如 string),若你希望路由名、Redux action.type、配置键等既是值又是精确类型,用字面量联合或 as const

const ROUTES = {
  login: '/login',
  list: '/manage/list',
} as const

type RoutePath = (typeof ROUTES)[keyof typeof ROUTES] // '/login' | '/manage/list'

function navigate(to: RoutePath) {
  /* ... */
}

as const 还会把数组变成只读元组,避免被推断成 string[] 丢失长度信息,对表格列配置等很有用。

2. 可辨识联合(Discriminated Union):替代「一堆可选字段 + any」

同一语义下多种形态,用公共字面量字段做标签,在 switch / if 里收窄,比一个大而全的接口更清晰、更安全:

type Loading = { status: 'loading' }
type Success<T> = { status: 'success'; data: T }
type Failed = { status: 'error'; message: string }

type AsyncResult<T> = Loading | Success<T> | Failed

function render<T>(r: AsyncResult<T>) {
  switch (r.status) {
    case 'loading':
      return <Spin />
    case 'success':
      return <List data={r.data} />
    case 'error':
      return <Alert message={r.message} />
  }
}

这比 result: any 或「所有字段都 optional」更能防止漏判分支,大家觉得呢?

3. satisfies:既要类型检查,又不要「宽化」字面量

当你写配置对象时,用 satisfies 可以在不把字面量类型擦成 string 的前提下,检查是否满足某个接口(TypeScript 4.9+):

const theme = {
  primary: '#1890ff',
  danger: '#ff4d4f',
} satisfies Record<string, string>

// theme.primary 仍是字面量窄类型,同时保证对象结构合法

适合用于 设置主题配置、权限映射、与后端约定的枚举表等场景,避免「为了通过检查把类型写的很宽」。

4. 内置工具类型:少手写重复结构

工具类型不等于炫技,而是减少重复,表达「从已有类型派生」的意图。常用组合:

工具类型典型用途
Partial<T>表单「编辑草稿」、patch 请求体
Required<T>与 Partial 相对,必填场景
Pick<T, K> / Omit<T, K>只要接口子集,避免复制粘贴字段
Record<K, V>字典、映射表
ReturnType<typeof fn>随函数返回值推导,改函数签名一处联动
Parameters<typeof fn>提取参数元组,做包装器时很有用
Awaited<T>解 Promise / thenable,比手写嵌套 Promise 更清晰

比如:API 层统一包装时,不必 any 承接返回值:

async function fetchJson<T>(url: string): Promise<T> {
  const res = await fetch(url)
  return (await res.json()) as T // 生产环境可再配合 zod / io-ts 做运行时校验
}

type User = { id: string; name: string }
type UserLoader = () => Promise<User>
type UserResolved = Awaited<ReturnType<UserLoader>>

5. React 里的泛型坑位:一次标对,少十次 any

优先在「源头」写泛型,而不是在子组件里吞 any

// useState:空数组必须带元素类型
const [list, setList] = useState<QuestionItem[]>([])

// useRef:DOM 与「可变容器」分开
const divRef = useRef<HTMLDivElement>(null)
const latest = useRef<string>('')

// 事件:用 React 自带类型,勿手写 (e: any)
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value)
}

函数组件 propsinterface / type 声明;
子组件若复用父级泛型,可写成 <T,>(props: ListProps<T>) => { ... } 形式,让列表项类型一路传下去。

6. keyof、索引访问与映射:安全地「按名字取类型」

从已有接口派生键名或属性类型,避免魔法字符串:

interface User {
  name: string
  age: number
}

type UserKey = keyof User // 'name' | 'age'

function getField<K extends keyof User>(u: User, key: K): User[K] {
  return u[key]
}

(obj as any)[key] 强太太太多啦;
日常 keyof + 泛型约束已能解决大量问题,复杂场景再结合模式匹配做提取就好啦。

7. 断言 as 与非空断言 !:应急用,且范围越小越好

类型断言能救火,但会告诉编译器「相信我」——与 any 一样要克制。即,断言成「下一步真实用到的最小形状」,而不是 as any

非空断言 value! 仅在逻辑上排除 null/undefined 时使用;若不确定,用可选链 ?.,比满屏 ! 更健康。


六、心态层:接受「边际收益递减」

提升类型覆盖越往后越费精力。当你觉得重构吃力、收益变小时,可能已经到了对你和项目都合适的平衡点——此时目标不再是「零 any」,而是 关键路径、边界、公共模块 足够严格。


写在最后

避免 anyScript,说到底就是别让类型检查在最关键的地方休眠

工程上靠 strict + Lint + 代码评审;
写法上用接口、泛型、unknown、类型守卫与内置工具类型;
认知上把体操当工具而不是炫技~~~