Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

一些ts技巧 #687

Open
981377660LMT opened this issue Jan 2, 2025 · 0 comments
Open

一些ts技巧 #687

981377660LMT opened this issue Jan 2, 2025 · 0 comments

Comments

@981377660LMT
Copy link
Owner

下面再补充一些更“高阶”、更有挑战性的 TypeScript 技巧,涵盖了声明合并、模块扩展、类型层面做逻辑运算与数据结构转换、模板字面量进阶玩法等方面,希望能进一步激发你对“类型编程”的兴趣。


1. 模块扩展(Module Augmentation)

有时我们需要给第三方库或已有模块添加额外类型、方法或属性,比如想给 expressRequest 对象添加一个自定义字段。这就需要用到模块扩展(Module Augmentation)。在 d.ts 声明文件或全局声明的环境下使用 declare module 'xxx' 来完成。

// 假设要给 express.Request 添加一个 currentUser 属性
declare module 'express-serve-static-core' {
  interface Request {
    currentUser?: {
      id: number
      name: string
    }
  }
}

// 之后在使用时即可:
app.use((req, res, next) => {
  req.currentUser = { id: 1, name: 'Alice' }
  next()
})
  • 注意:要保证在编译配置 tsconfig.json 中,"skipLibCheck""allowJs" 等选项不会导致对声明文件的忽略,否则可能无法生效。

4. 声明合并(Declaration Merging)与 Interface vs. Type 的混合技巧

4.1 声明合并

TypeScript 中,对同名的 interface 进行多次声明时,会发生声明合并(Declaration Merging)。有些时候我们需要刻意利用这种特性来拆分大型类型,或者给现有类型打补丁。

interface MyLibOptions {
  debug?: boolean
}

// 在别处,再声明同名 interface
interface MyLibOptions {
  version?: string
}

// 最终合并为
// interface MyLibOptions {
//   debug?: boolean;
//   version?: string;
// }

4.2 Interface 与 Type 混合

  • interface 支持声明合并,但不支持条件类型、内部 infer 等。
  • type 更灵活,能写条件类型、联合、交叉等高级玩法,但不支持声明合并。

在大型项目中,常常会同时使用 interfacetype 来取长补短。比如主体结构用 interface,需要高级条件处理、模板字面量时再用 type 进行扩展或转换。


6. 复合泛型:让类型工具串联起来

很多时候,单独一个条件类型或映射类型不足以应付更复杂的变换,需要将多个泛型工具串联使用,形成“管道”的效果。比如先过滤键,再把剩余的键都变成只读,最后对某些字段做模板转换。

一个可能的示例思路:

// 1) 先过滤出非函数的属性
type RemoveFuncs<T> = {
  [K in keyof T as T[K] extends Function ? never : K]: T[K]
}

// 2) 将结果做深度只读
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}

// 3) 组合使用
type Composite<T> = DeepReadonly<RemoveFuncs<T>>

只要定义好每个“环节”的工具类型,最终再组合使用,就能在类型层面随心所欲地“变形”你的数据结构。


7. 类型层面的“缓存”与“记忆化”技巧(Type Memoization)

当做复杂的递归类型变换时,可能遇到编译性能问题或递归深度过大的问题。部分社区开发者会利用**类型层面的“映射表”**或“内存对象”来缓存已计算过的类型结果,从而降低递归次数(这并非官方提供的功能,而是一种“hack”思路)。

示例:展示思路,但实际可读性较低,使用需谨慎。

// 简略示例,仅说明思路
type Memo<T, Cache extends object> = T extends keyof Cache
  ? Cache[T]
  : /* 进行计算,并把结果写回 Cache*/ any
  • 这种“类型记忆化”有一定场景价值,但可读性和可维护性都很差,只有在极端性能瓶颈或极端递归深度场景下才考虑。

8. 利用 “Branded Type + 交叉” 实现 Opaque Type

“Branding” (或称 Opaque Type)是一种在 TS 中创建逻辑上不可互换的标识类型的常用技巧。
更进一步的用法是利用交叉将品牌信息藏起来,在外部不可访问,但依然能在类型检查时进行区分。

type Opaque<K, T> = T & { readonly __TYPE__: K }

type USD = Opaque<'USD', number>
type CNY = Opaque<'CNY', number>

function payInUSD(amount: USD) {
  /* ... */
}

const usdAmount = 100 as USD
const cnyAmount = 100 as CNY

payInUSD(usdAmount) // OK
payInUSD(cnyAmount) // Error
  • 在外部,它们都表现为 number,但编译器会识别到它们带有不同的品牌而拒绝错误用法,从而避免把人民币当美元来支付。
  • 这是业务层面常见的防止“同质异义”值混用的一种方式。

9. 使用 symbol 做更安全的对象键

TypeScript 对 symbol 键也提供了良好的支持,可以用于增强你的对象接口,防止与常规字符串键发生冲突。

const secretKey = Symbol('secret')

interface SecretData {
  [secretKey]: string // symbol 作为对象键
  visible: boolean
}

const data: SecretData = {
  [secretKey]: 'my-secret',
  visible: true
}

// 普通脚本层面无法轻易枚举到 secretKey,但 TS 能识别它
  • 在某些场景里,我们需要在对象上挂载“不可枚举或不常规访问”的私有/特殊字段,就可以利用 symbol 来保证一定程度的“不可冲突”且仍有静态类型提示。

10. 复杂函数的重载 + 泛型推断 + 条件约束

当函数的参数签名非常多元化,需要根据不同的参数类型返回不同的结果类型时,可能单纯的“联合 + 类型守卫”还不够灵活,就要结合重载声明内部条件类型,并在实现中推断与区分。

interface ParamA {
  kind: 'A'
  value: string
}
interface ParamB {
  kind: 'B'
  value: number
}

function complexAPI(param: ParamA): string
function complexAPI(param: ParamB): number
function complexAPI(param: ParamA | ParamB): string | number {
  if (param.kind === 'A') {
    return param.value // string
  } else {
    return param.value // number
  }
}

// 使用
const r1 = complexAPI({ kind: 'A', value: 'hello' }) // r1: string
const r2 = complexAPI({ kind: 'B', value: 123 }) // r2: number
  • 对于“多态”API,TypeScript 重载签名可以在调用端提供更精准的提示;内部实现结合类型守卫保证逻辑安全。
  • 如果签名过多,也可以考虑改成“联合 + 类型守卫 + 条件类型”方式(不写重载,而是一个函数根据入参联合类型来做内部区分),两种模式各有利弊,需要看具体项目需求和代码可读性来取舍。

1. 利用条件类型做“模式匹配”(Pattern Matching)

虽然 TS 不直接支持类似 Scala / F# / Rust 那种“模式匹配”,但我们可以在类型层面通过联合类型 + 条件类型 + 类型谓词,模拟出部分“模式匹配”的效果。例如,对一个联合类型进行分支解析

type Action =
  | { type: 'increment'; payload: number }
  | { type: 'decrement'; payload: number }
  | { type: 'reset' }

type Match<T extends Action> = T extends { type: 'increment'; payload: infer N }
  ? `Increment by ${N}`
  : T extends { type: 'decrement'; payload: infer M }
    ? `Decrement by ${M}`
    : T extends { type: 'reset' }
      ? 'Reset'
      : never

// 使用:
type A1 = Match<{ type: 'increment'; payload: 5 }> // "Increment by 5"
type A2 = Match<{ type: 'reset' }> // "Reset"
  • 通过“多重条件”对不同的“action”做类型级区分,就像在模式匹配中“解构”出 payload
  • 函数实现中,也可以用类型守卫来模拟运行时的匹配。

2. 利用 asserts 关键字声明自定义“断言函数”

自 TS 3.7 起,TypeScript 引入了 asserts condition 这种声明方式,可以告诉编译器:如果断言函数正常返回(未抛出),则表明传入的值满足某种条件。这在测试或校验阶段非常有用。

function assertIsNumber(value: unknown): asserts value is number {
  if (typeof value !== 'number') {
    throw new Error('Not a number!')
  }
}

function doSomething(x: unknown) {
  // 这里 x 是 unknown
  assertIsNumber(x)
  // 通过断言函数后, x 就变成了 number
  console.log(x.toFixed(2))
}
  • 这和“类型谓词” ((x: unknown): x is number) 类似,但 asserts 更偏向“抛错式”断言。
  • 可以在写单元测试运行时校验时带来更好的类型推断

5. 利用“条件类型 + 联合类型”对函数参数做类型分解

有时候我们需要一个函数能根据参数不同,返回不同的类型。如果不想写多重重载声明(太多签名),可以在一个函数里结合联合类型 + 条件类型进行内部分支,比如:

interface CaseA {
  kind: 'A'
  data: string
}
interface CaseB {
  kind: 'B'
  data: number
}
type UnionCase = CaseA | CaseB

function handleCase<T extends UnionCase>(obj: T): T extends CaseA ? string : number {
  if (obj.kind === 'A') {
    // 这里用断言回避编译器烦恼
    return obj.data as any
  } else {
    return obj.data as any
  }
}

// 使用:
const r1 = handleCase({ kind: 'A', data: 'hello' }) // 类型推断为 string
const r2 = handleCase({ kind: 'B', data: 123 }) // 类型推断为 number
  • 这样的函数签名更简洁,但实现内部可能需要一些类型断言(因为 TS 并不自动根据“外部返回类型”进行分支限制)。
  • 也可以在实现里使用类型谓词或类型守卫来减少断言。

6. 使用 “type extends never” 做静态分支判断

有时想在类型层面做“布尔条件”,并返回不同类型,但 TS 并没有布尔型的 true / false 这种类型可以直接操作。可以利用“T extends never”是否为 true 的特性来模拟:

type If<Cond extends boolean, TrueType, FalseType> =
  // Trick: if Cond is true => never extends false. If Cond is false => never extends true.
  // 当然还要配合分发写法
  Cond extends true ? TrueType : FalseType

或者一些社区的更“黑科技”写法,会用“never extends 'x'”之类的比较,模拟“真假判断”。这类技巧非常晦涩,除非你在写一个复杂的类型工具库,否则不建议大规模使用。


7. 巧用 strictBindCallApply 提升函数调用的类型安全

tsconfig.json 中启用 "strictBindCallApply": true 后,TypeScript 会对 Function.prototype.bind/call/apply 做更严格的类型检查。

  • 例如,当你使用 obj.fn.call(context, arg1, arg2)obj.fn.apply(context, [args...]) 时,TS 会自动推断并校验 context 类型是否匹配 this,以及参数是否匹配 fn 的形参类型。
  • 这在使用一些需要手动绑定上下文的老式 OOP 或第三方库 API 中非常有用,能避免很多潜在的“绑定错误”。
function greet(this: { user: string }, msg: string) {
  console.log(`${msg}, ${this.user}`)
}

const ctx = { user: 'Alice' }
greet.call(ctx, 'Hello') // 通过检查
// greet.call({}, 'Hello'); // 报错:{} 上没有 user

8. 泛型中使用 this 类型,实现“链式”方法的类型推断

在写一些“链式 API”时,我们可能想在基类里声明一个返回 this 的方法,让派生类也能保持正确的类型。可以通过“this 类型”或“this 泛型”实现:

class FluentBuilder {
  private _values: Record<string, any> = {}

  set(key: string, value: any): this {
    this._values[key] = value
    return this // 返回 this
  }

  build(): Record<string, any> {
    return this._values
  }
}

class MyBuilder extends FluentBuilder {
  specialMethod(): this {
    // ...
    return this
  }
}

const builder = new MyBuilder()
  .set('a', 1)
  .specialMethod() // 返回 MyBuilder,而不是 FluentBuilder
  .build()
  • 这样就能在继承的链式 API 中,让方法返回派生类自身类型,而不是父类,保持类型“流转”一致。

9. 在 React 中处理“ForwardRef + 泛型组件”

在 React 中编写泛型组件或需要转发 RefforwardRef)时,有时候类型比较复杂。可以使用 React.ForwardRefRenderFunctionReact.ForwardRefExoticComponent 等内置类型,并把泛型也穿进去。例如:

import React from 'react'

type InputProps<T> = {
  value: T
  onChange: (val: T) => void
}

function GenericInput<T>(props: InputProps<T>, ref: React.Ref<HTMLInputElement>) {
  // ...
  return <input ref={ref} />
}

const ForwardedInput = React.forwardRef(GenericInput) as <T>(
  props: InputProps<T> & React.RefAttributes<HTMLInputElement>
) => JSX.Element

// 使用
function App() {
  const ref = React.useRef<HTMLInputElement>(null)
  return <ForwardedInput<number> value={123} onChange={v => console.log(v)} ref={ref} />
}
  • 这里利用类型断言 as <T>(props: ...) => ... 来保留泛型参数,配合 React.forwardRef
  • 对函数式组件 + ref 转发的场景,TS 仍不算特别优雅,但可以通过这样的小技巧来兼顾泛型传递与 ref 类型安全。

10. “Strip out never” 操作、在元组或数组里排除不需要的类型

当把多个类型条件组合到一个数组或元组中时,可能会产生 never;如果我们想把那些 never 剔除,可以先把它们变成“可选项”再过滤。例如:

// 将元组里的 never 元素移除
type FilterNever<T extends any[]> = T extends [infer Head, ...infer Tail]
  ? Head extends never
    ? FilterNever<Tail> // 直接跳过
    : [Head, ...FilterNever<Tail>]
  : []

// 使用
type Mixed = [number, never, string, never]
type Filtered = FilterNever<Mixed> // [number, string]
  • 这种递归“拆头 + 处理 + 继续”的写法在 TypeScript 类型编程中非常常见。
  • 可以结合“可选类型 + 剩余参数 + 元组长度推断”等进一步扩展,实现更多花式操作。

2. 自定义泛型“推导约束”让参数更灵活(推断 + 约束二合一)

在某些场景下,我们想让函数参数在被推断为泛型类型时,同时还要对其再进行一些约束。例如:

function wrapArray<T extends number | string>(input: T | T[]): T[] {
  return Array.isArray(input) ? input : [input]
}

// 使用:
const arr1 = wrapArray(123) // 推断为 number[] -> [123]
const arr2 = wrapArray([456, 789]) // 推断为 number[] -> [456, 789]
const arr3 = wrapArray('hello') // 推断为 string[]
// const arr4 = wrapArray(true);    // 报错,true 不满足 T extends number | string
  • 这里的 T 必须是 numberstring,但是我们也接受输入 T 或者 T[],并最终返回 T[]
  • 通过 T extends number | string 这种写法,让 TS 可以在推断的同时保证类型正确。

3. 使用 keyof any 创建可索引类型 (string | number | symbol)

在 TypeScript 里,keyof any 会得到 string | number | symbol,这是对象可用作键的所有类型合集。有时想做一个可以用作各种键的“字典”结构,可以这样写:

type Dictionary<T> = {
  [K in keyof any]: T
}

// 实际上就是 Record<string | number | symbol, T>
type MyDict = Dictionary<number>
// 等价于 { [x: string]: number; [x: number]: number; [x: symbol]: number }
  • 在绝大部分场景里,你会使用 Record<string, T>Record<PropertyKey, T> 就够了。
  • keyof any 这个小技巧在编写类型工具时有时能派上用场。

4. 在“对象”与“元组”之间做类型变换

有时我们需要把对象的每个属性变成一个数组/元组元素,或者反之将元组/数组的元素转换成对象的键。通过映射类型 + 索引访问,可以做“对象 <-> 元组”之间的结构转换。

4.1 对象转元组

type ObjectToTuple<T extends Record<PropertyKey, unknown>> = {
  [K in keyof T]: [K, T[K]]
}[keyof T]

// 使用
type Example = { a: 1; b: 2; c: 3 }
type Result = ObjectToTuple<Example>
// => [ "a", 1 ] | [ "b", 2 ] | [ "c", 3 ]

这个类型本身得到一个联合类型,你可以再进一步将其收敛成数组或做其他操作。

4.2 元组转对象

如果你有一个键值对形式的元组,可以再用映射类型“重映射键”,把它变成对象结构:

type TupleToObject<T extends [PropertyKey, unknown][]> = {
  [P in T[number] as P[0]]: Extract<T[number], [P[0], unknown]>[1]
}

// 传入类似 [ ['a', 1], ['b', 2], ['c', 3] ]
// 期望得到 { a: 1; b: 2; c: 3 }
  • 这类操作常用于在类型层面做结构化转换,尤其当你想限制某些结构必须是一组“键值对数组”并能最终得到一个静态对象类型时,这些技巧会非常有用。

5. “泛型”与“异或类型 (XOR Type)” —— 保证只能传一组属性

在业务里有时会遇到这样的需求:只能在一组属性中选其中一组,但不允许多选,也不允许都不选。可以自定义一个 XOR(exclusive or)类型来实现此逻辑。例如,你要么传 email 要么传 phone,不允许同时存在或都不存在。

type XOR<T, U> = T | U extends object
  ?
      | (T & Partial<Record<Exclude<keyof U, keyof T>, never>>)
      | (U & Partial<Record<Exclude<keyof T, keyof U>, never>>)
  : T | U

// 使用
interface ByEmail {
  email: string
  phone?: never
}
interface ByPhone {
  phone: string
  email?: never
}

type Contact = XOR<ByEmail, ByPhone>

const c1: Contact = { email: '[email protected]' } // OK
const c2: Contact = { phone: '1234567' } // OK
// const c3: Contact = {};                      // Error:必须选其一
// const c4: Contact = { email: 'x', phone: 'y'}; // Error:只能选一个
  • 通过这种“XOR”约束,就能在类型层面“强制”只选一组字段,非常适合有互斥选项的场景。
  • 实现方式可以有多种,上面只是一个常见示例。

6. React 中声明事件类型的“提取 + 推断”,避免手动书写繁琐的 DOM 事件类型

在 React 项目中,我们常常写 React.MouseEvent<HTMLDivElement> 之类的类型,但在一些场景下我们可以自动推断。比如:

function Button(props: React.HTMLProps<HTMLButtonElement>) {
  return <button {...props} />
}

// 这样当你在使用 <Button onClick={...} /> 时,TS 会自动推断出 onClick 是 `MouseEvent<HTMLButtonElement> => void`
// 无需手动写事件类型。
  • 如果你想更灵活,可以利用一些条件类型去提取某个组件可能支持的所有 props,甚至限制只有事件相关的部分。
  • 对于需要手写事件类型的情况,也可以通过 JSX.IntrinsicElements['button'] 等写法来获得原生 <button> 元素的类型定义,避免重复。

7. “Intersection”+“Mapped”+“Generic” 三连,做极度灵活的校验或配置

在某些场景,需要把多个相似的配置接口“合并”到一起,还想在属性层面做一部分处理,就可以用交叉类型(intersection)与映射类型配合。例如,我们有多个插件配置,每个插件都有自己的格式:

interface PluginAConfig {
  name: 'plugin-a'
  enabled: boolean
  threshold: number
}
interface PluginBConfig {
  name: 'plugin-b'
  enabled: boolean
  path: string
}

现在想写一个“总的配置”,自动收敛成 { 'plugin-a': PluginAConfig; 'plugin-b': PluginBConfig; ... } 之类的结构:

type ConfigOf<T> = T extends { name: infer Name extends string } ? { [K in Name]: T } : never

type Merged<T extends any[]> = UnionToIntersection<
  T[number] extends any ? ConfigOf<T[number]> : never
>

// 辅助类型,将联合转为交叉
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer R) => void
  ? R
  : never

// 使用
type MyPlugins = Merged<[PluginAConfig, PluginBConfig]>
// => { 'plugin-a': PluginAConfig } & { 'plugin-b': PluginBConfig }
// => { 'plugin-a': PluginAConfig; 'plugin-b': PluginBConfig }
  • 最后就可以得到一个复合对象类型,其中每个键都对应一个插件的配置类型。这种设计常见于一些“插件化系统”或“多模块配置”场景里。

8. 在 enum 与“字面量联合”之间做转换

枚举 enum 是 TypeScript 的一大特色,但有时我们不想直接使用 enum,更倾向使用字面量联合(因为编译后不会多出额外的 JS 代码)。如果你需要在类型层面做二者的互操作,可以这样做:

// 声明枚举
enum ColorEnum {
  Red = 'RED',
  Green = 'GREEN',
  Blue = 'BLUE'
}

// 将枚举值收集为联合类型
type ColorLiteral = `${ColorEnum}`
// => "RED" | "GREEN" | "BLUE"

// 如果需要反向把字面量字符串映射到枚举,可以用一个辅助对象:
const ColorMap = {
  RED: ColorEnum.Red,
  GREEN: ColorEnum.Green,
  BLUE: ColorEnum.Blue
} as const

// 此时可以做类似:
function paint(color: ColorLiteral) {
  const c = ColorMap[color]
  // c 的类型是 ColorEnum.Red | ColorEnum.Green | ColorEnum.Blue
  // ...
}
  • 也可以完全放弃 enum,直接用 as const 的对象与字面量联合的方式来模拟枚举,这样可能更灵活,也无额外编译产物。

9. 利用类型参数默认值来减少繁琐的泛型调用

在编写一些大型泛型时,给类型参数设置默认值往往能减少调用者的心智负担。就像在函数里给形参设默认值一样:

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'

interface RequestConfig<M extends HttpMethod = 'GET'> {
  method?: M
  url: string
  // ...
}

// 使用
const config1: RequestConfig = { url: '/api' }
// method 会默认推断为 'GET'

const config2: RequestConfig<'POST'> = { method: 'POST', url: '/submit' }
  • 在复杂的库设计中,如果某些类型参数使用频率不高,可以提供默认值,让用户只在特殊场景下才传入自定义类型参数。

10. 条件类型与“空数组”/“空对象”陷阱

在写一些条件类型时,如果传入空数组 [] 或空对象 {},可能会出现意外的分发或推断结果。例如 [] 可能被推断为 never[]readonly [] 之类。为避免踩坑,常常要加上一些显式的“兜底处理”或“中间断言”。比如:

function processArray<T>(arr: T extends any[] ? T : never) {
  // ...
}

// 这里空数组可能有时 TS 推断的类型会让人惊讶,
// 需要在调用方手动断言或在函数内部做额外检查。
  • 如果要兼容空数组或空对象场景,最好提前设计好对应的条件分支或使用类型断言,让 TS 知道这是一个“正常值”,避免奇怪的推断导致编译错误或泛化为 any[] / {}

下面再给你补充一批更偏“冷门”或“技巧性超高”的 TypeScript 玩法,部分内容在社区中被称为**“类型体操”(type gymnastics),也有些是工程中潜在会遇到的高级用法。它们往往伴随更高的认知难度编译器负担**,在需要极致灵活度或特殊需求时会显得非常“好用”,但也要谨慎评估维护成本。


1. 使用 declare global 扩展全局类型或 Window 对象

有时候我们需要在全局作用域里增补自定义的属性、函数或类型,比如往 Window 上挂载一些全局变量,或者给 Node.js 中的 global 增加字段。这时可以在一个 .d.ts 文件或带有 declare 的文件中使用 declare global { ... }

// global.d.ts
export {}

declare global {
  interface Window {
    myGlobalVar?: string
  }

  // 在 Node.js 中也可以:
  namespace NodeJS {
    interface Global {
      myGlobalFlag?: boolean
    }
  }
}
  • 之后在你的项目代码里,就可以愉快地使用 window.myGlobalVarglobal.myGlobalFlag
  • 要确保 tsconfig.json 中没有排除这些声明文件(skipLibCheckexclude 等配置不要忽略它们)。

2. 变长(Variadic)元组类型:函数管道、柯里化等场景

当一个函数需要将参数“逐级传递”给下一个函数,或做类似函数管道 / 柯里化的操作时,可能要在类型层面处理不定长度的参数元组。TypeScript 支持“变长元组类型” (...T),可用在泛型上做灵活接收与传递。

// 简化示例:将多个函数组合为一个函数,以管道形式顺序调用
type Pipe<
  Fns extends Array<(arg: any) => any>
> = Fns extends [infer First, ...infer Rest]
  ? First extends (arg: infer A) => infer B
    ? Rest extends Array<(arg: B) => any>
      ? (arg: A) => ReturnType<Rest[Rest['length'] - 1]>
      : never
    : never
  : never;

// 当你传入若干函数 [f1, f2, f3,...],就能自动推断整个管道函数的入参、出参。
  • 这类写法内部往往要用到递归或“逐次提取头部 + 尾部”的逻辑,还要注意编译器的最大递归深度。
  • 实际上功能较复杂时通常会借助第三方的工具库(例如 ts-toolbelt)来实现高阶管道 / 柯里化。

3. “类型级字典合并”——多对象 / 表单配置的合并

当我们想在类型层面将多个“对象配置”合并成一个大对象(类似多源合并),可以先把对象们变成联合类型,再做Union -> Intersection,最后用键重映射来实现“同名键如何处理”的逻辑。

type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer R) => void
  ? R
  : never

// 这是最简单的合并:优先第一个定义的键,后面的键会覆盖
// 如果想做更精细的冲突策略,需要自行写更多条件逻辑
type MergeAll<T extends object[]> = UnionToIntersection<T[number]>

// 使用
type A = { foo: number; shared: 'A' }
type B = { bar: string; shared: 'B' }
type C = { baz: boolean }
type Merged = MergeAll<[A, B, C]>
// => { foo: number; shared: 'A' } & { bar: string; shared: 'B' } & { baz: boolean }
// 编译器最终可能收敛为 { foo: number; bar: string; baz: boolean; shared: 'B' }
  • 如果想更“深度”地合并嵌套字段,还要写“递归合并”。
  • 这种“类型级合并”可以用于表单系统插件配置等复杂需求。

4. “类型级子集 / 超集”——基于联合类型的权限或角色控制

在类似权限或角色(RBAC,Role-Based Access Control)的系统中,可能需要在类型层面定义“子集”与“超集”的关系。比如在 TS 中,我们可以把角色权限表示成一个联合类型,再进行“部分选择”(子集)或“拓展”(超集)判断。

type AllPermissions = 'read' | 'write' | 'delete'

type SubsetCheck<S extends AllPermissions> = S
// 只能是 'read'、'write'、'delete' 的子集

// 定义某个角色的权限
type RoleUser = 'read'
type RoleAdmin = 'read' | 'write' | 'delete'

// 如果想写一个函数,只允许传入权限全集或它的任意子集:
function setPermissions<P extends AllPermissions[]>(perms: P) {
  // ...
}

setPermissions(['read']) // OK
setPermissions(['read', 'write']) // OK
setPermissions(['read', 'write', 'delete']) // OK
// setPermissions(['xxx']);            // Error
  • 这种思路可在编译期帮我们捕获“非法权限”传入的问题。
  • 如果要做更复杂的“权限矩阵”或“权限依赖”校验,可以在类型层面加更多约束;不过可能会极度复杂。

6. “类型级 BFS / DFS”:flatten 或 unflatten 嵌套对象

在一些极度复杂的数据结构处理场景,需要将嵌套对象“打平” (flatten),或把“路径式”键值对“还原” (unflatten). 这可以在类型层面做,但实现起来往往非常复杂,需要递归处理键与嵌套对象。

示例:简化的 Flatten 类型(递归将子对象属性拼接为 foo.bar 形式):

type Flatten<T extends object, Path extends string = ''> = {
  [K in keyof T & string]: T[K] extends object
    ? Flatten<T[K], Path extends '' ? K : `${Path}.${K}`>
    : Record<Path extends '' ? K : `${Path}.${K}`, T[K]>
}[keyof T & string] // 将所有结果用索引访问再合并成联合类型
  • 再基于 UnionToIntersection 之类的技巧把它从联合类型收敛成一个对象
  • 如果结构非常深,编译器容易碰到递归极限;也可能出现性能瓶颈。

7. “收窄泛型”:为同一个函数写多个“专用签名”再结合一个通用实现

TypeScript 的函数重载声明可以让我们为同一个函数写多种签名,但有时我们想在同一个泛型函数里做针对不同 T 值的专门处理,可以写“有条件分支”的类型签名。

function fancyFunc<T>(x: T): T extends string ? number : T extends number ? boolean : unknown {
  if (typeof x === 'string') {
    return x.length as any // number
  } else if (typeof x === 'number') {
    return (x > 0) as any // boolean
  }
  return 'fallback' as any // unknown
}

const a = fancyFunc('hello') // a: number
const b = fancyFunc(123) // b: boolean
const c = fancyFunc(true) // c: unknown
  • 好处是调用端只见到一个函数,但能感受到多态性的返回类型。
  • 缺点是函数实现里不可避免要做一些“强行断言”或 as any,因为 TS 无法自动根据返回类型作流动分析。

8. 在函数参数处做“类型安全解构”并保留整套推断

当一个函数的参数是个大型对象或深度结构,需要解构出多个字段并保留类型信息时,可以先用泛型接住整个对象,接着在内部“分解”这个泛型,从而避免写一堆繁琐的接口或 type:

function processData<T extends { a: number; b: string; nested: { x: boolean } }>({
  a,
  b,
  nested: { x }
}: T) {
  // 这里 a, b, x 都是正确类型
  console.log(a.toFixed(), b.toUpperCase(), x.valueOf())
}

// 虽然看似我们给 T 做了固定的结构,但在大型场景里可以使用
// T extends SomeGenericConstraint to keep it open for more fields.
  • 如果只是写 ({ a, b, nested: { x } }: { a: number; b: string; nested: { x: boolean } }) => ... 也行,但那样的函数就不具备泛型扩展能力。
  • 通过 <T extends ...> 可以在外部额外传一些属性,不受强制限制,但仍保留对 a, b, nested.x 的精确检查。

10. 自定义 ESLint 规则 + TypeScript AST,让代码风格与类型信息融合检查

在更专业的工程实践中,有时不光需要类型检查,还要结合AST 检查来编写自定义的 ESLint 规则(或 TSLint 的旧时代)。可以使用官方提供的 @typescript-eslint/parser 以及 @typescript-eslint/experimental-utils 去写访问器,从而在编译时拿到类型信息、在 ESLint 时拿到代码 AST,实现类似 “仅当函数返回值是 Promise 时,必须使用 async”等更高级的语法+类型混合规则。

// 伪代码示例
createRule({
  name: 'require-async-if-return-promise',
  meta: {
    /* ... */
  },
  defaultOptions: [],
  create(context) {
    const services = context.parserServices
    const checker = services.program.getTypeChecker()

    return {
      FunctionDeclaration(node) {
        // 通过 AST + type checker 拿到函数返回类型
        const tsNode = services.esTreeNodeToTSNodeMap.get(node)
        const signature = checker.getSignatureFromDeclaration(tsNode)
        const returnType = signature && checker.getReturnTypeOfSignature(signature)
        // 如果 returnType 是个 Promise,就检查它是不是 async,否则报错
        // ...
      }
    }
  }
})
  • 这种结合在大型项目里能实现更细粒度的代码规范或类型策略检查。
  • 当然编写成本和学习难度都不小。

8. 用“额外类型参数”去承载中间状态,做更复杂的链式调用

当你写链式 API 时,可能不仅想保留当前的“上下文类型”,还想同时保存一些中间状态历史记录。可以借助“额外的泛型参数”来承载这部分信息,每次调用都返回“新的泛型上下文”:

class Chain<Steps extends any[] = []> {
  doAction<T>(val: T) {
    // 返回一个新的链式对象,把 val 推入 Steps
    return new Chain<[...Steps, T]>()
  }

  getSteps(this: Chain<Steps>): Steps {
    // 这里 Chain<Steps> 意味着我们知道 Steps 是什么
    // ...
    return [] as unknown as Steps
  }
}

// 使用
const chain = new Chain().doAction('first').doAction(2).doAction(true)

type StepsType = ReturnType<(typeof chain)['getSteps']>
// => [string, number, boolean]
  • 通过这样在类型层面保留每一步的记录,你甚至可以在编译期对后续操作做出不同的限制。
  • 需注意可能会带来编译性能的损耗,因为 Steps 不断变长会增加类型复杂度。

9. “Overlay” 技巧:将一个类型拆分为若干可局部覆盖的部分

在大型项目中,有时想先声明一个基础类型,然后在多个不同文件里对其某些字段进行“覆盖”,再合并回去使用。可以用交叉类型(&)或映射类型做“Overlay”:

interface BaseConfig {
  name: string
  version: string
}

// 在别的文件
type OverlayA = {
  version: `${number}.${number}`
  isBeta?: boolean
}

// 最后合并
type FinalConfig = BaseConfig & OverlayA

// => {
//   name: string;
//   version: string & `${number}.${number}`;  // 交叉后变得更严格
//   isBeta?: boolean;
// }
  • 有时会使用 Partial<BaseConfig> 之类的做“增量覆盖”,配合 & 合并成一个最总结构。
  • 如果需要“只合并部分字段或删除字段”,也可以用“Key Remapping + Omit + 交叉”来灵活控制。

10. 借助 typescript-is 或类似库,在运行时与编译期同步做类型校验

TypeScript 的类型系统是静态的,如果还想在运行时验证一个值真的符合声明的接口,可以借助第三方库,例如 typescript-ists-json-schema-generator + ajv 等。

  • 原理:通过自定义 TypeScript Transformer分析你的类型,自动生成运行时校验代码。
  • 这样就实现了编译期 + 运行时的类型双保险:编译期防止你写错,运行时防止外部输入(如接口请求)不符合类型。

示例(基于 typescript-is):

import { is } from 'typescript-is'

interface User {
  id: number
  name: string
}

// 假设从后端拉回了一个 JSON
const data = JSON.parse('{"id":1,"name":"Alice"}')

// 在运行时用 is<User>() 验证
if (is<User>(data)) {
  console.log('Valid user', data.name)
} else {
  console.error('Invalid user data!')
}
  • 需要配置自定义 transformer,并且它只在编译阶段生效,带来一定复杂度。
  • 但对于安全性要求高、或 schema 常变的场景,这种编译期 + 运行时的类型校验是非常有价值的。

1. 巧用 “至少一个属性” (RequireAtLeastOne)

在业务中常会遇到“这个对象里至少要有一个字段是必填,其他是可选”的需求。可以写一个工具类型来强制此规则,比如一个搜索条件对象,你要求至少有一个搜索项不是空。

type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Keys extends unknown
  ? Pick<T, Keys> & Partial<Record<Exclude<keyof T, Keys>, never>>
  : never

// 使用:要么提供 `name`,要么提供 `age`,两者至少出现一个
interface PersonFilter {
  name?: string
  age?: number
}

type PersonFilterAtLeastOne = RequireAtLeastOne<PersonFilter, 'name' | 'age'>

const f1: PersonFilterAtLeastOne = { name: 'Alice' } // OK
const f2: PersonFilterAtLeastOne = { age: 30 } // OK
// const f3: PersonFilterAtLeastOne = {};             // Error: 必须至少有一个属性
// const f4: PersonFilterAtLeastOne = { name: 'Alice', age: 30 }; // 也行
  • 该技巧可用于表单校验筛选条件业务配置等场景,让类型层面先保证“至少选一项”。

2. “只能且必须有一个属性” (RequireExactlyOne)

与上面类似,但更严格:只能也必须选择其中一个键(互斥属性)。在后端或前端的接口中常见:例如“要么传 email,要么传 phone,但不能同时传或都不传”。

type RequireExactlyOne<T, K extends keyof T = keyof T> =
  K extends unknown
    ? (Record<K, T[K]> & Partial<Record<Exclude<keyof T, K>, never>>)
      extends infer O
      ? { [P in keyof O]: O[P] }
      : never
    : never;

// 使用
interface ContactInfo {
  email?: string;
  phone?: string;
}

type ContactExactlyOne = RequireExactlyOne<ContactInfo, 'email' | 'phone'>;

const c1: ContactExactlyOne = { email: '[email protected]' }; // OK
const c2: ContactExactlyOne = { phone: '123-4567' };      // OK
// const c3: ContactExactlyOne = {};                      // Error:必须选一个
// const c4: ContactExactlyOne = { email: 'x', phone: 'y' }; // Error:只能选一个
  • 能在 TS 编译期就帮你避免常见的“互斥字段”错误。

3. 处理 “可选属性” vs. “属性可为 undefined” 的区别

在声明接口或类型时,? 代表该属性是可选的,而 | undefined 代表它存在但值可能是 undefined。在实践中这两者会影响到类型守卫、对象结构等很多细节:

interface OptionalProp {
  name?: string
  // TS 认为可以不存在 name,也可以 name: undefined
}

interface UndefinedableProp {
  name: string | undefined
  // TS 认为一定存在 name 这个键,但它的值可能是 undefined
}
  • 如果你需要区分“属性是否完全缺失”与“属性存在但为 undefined”,务必谨慎选用这两种写法。
  • 在一些深度合并或表单更新逻辑中,这种细微差别可能非常关键。

4. 内置 ConstructorParametersInstanceType:推断类的实例与构造函数参数

在面向对象场景里,有时我们需要获取某个类的构造参数类型,或获取类的实例类型。TypeScript 内置了以下 Utility Types:

class Person {
  constructor(
    public name: string,
    public age: number
  ) {}
}

type Params = ConstructorParameters<typeof Person>
// => [name: string, age: number]

type PersonInstance = InstanceType<typeof Person>
// => Person
  • 这种技巧在工厂函数依赖注入或一些“动态创建实例”场景里非常实用,能让你自动获取构造函数的签名或实例类型,而不必手动维护。

5. ReturnType + Overloaded Function 场景下的最佳实践

当函数有重载签名时,ReturnType 只会获取到最后一个实现签名的返回类型。如果想分别获取各个重载的返回类型,需要借助其他技巧(如条件类型、类型级分发等)。
一个常见的做法是:不写多重重载声明,而是写一个联合类型参数并在实现里做类型守卫,以保证 ReturnType 能正常推断具体分支。

// 传统重载写法
function test(x: string): number
function test(x: number): string
function test(x: any) {
  /* ... */
}

type TR = ReturnType<typeof test>
// => string (它只取到实现签名的返回类型)

// 如果你改成一个函数 + 联合类型参数:
function test2(x: string | number) {
  return typeof x === 'string' ? x.length : x.toFixed(2)
}

type TR2 = ReturnType<typeof test2>
// => number | string   (正确覆盖了两个分支)
  • 如果你确实需要多重重载,且想获得每条重载的返回类型,就得写相对复杂的类型体操,不算太“实用”。常见方案还是统一用一个函数 + 联合类型即可。

8. Type Alias + Utility:封装你的“业务语义”

在实际开发中,我们常会面对类似 “ID 类型”、“Token 类型”、“URL 类型”等,它们本质上都是 string,但含义不同,不能混用。可以用品牌化(Branding) 或最简单的type alias 来让代码更可读:

// 简单别名
type UserId = string
type OrderId = string

// 更严格的品牌化
type Brand<K, T> = T & { __brand: K }
type UserIdBranded = Brand<'UserId', string>
type OrderIdBranded = Brand<'OrderId', string>

function getUser(id: UserIdBranded) {
  /* ... */
}

const uid = 'abc123' as UserIdBranded
getUser(uid)

// getUser('some-random-string'); // Error, 不是品牌化过的
  • 这样在编译期就能避免拿 OrderIdUserId 用,而在代码可读性上也更清晰“这是什么 ID”。

下面再为你补充一批更贴近日常开发、更偏“实战”而且依旧能展现 TypeScript 灵活性与安全性的技巧与思路。这些技巧大多在写业务代码、对接接口或组织项目结构时能起到明显的便利作用,希望对你的项目有所帮助。


1. 利用 keyof typeof 创建从对象/常量自动生成的“键”联合类型

有时我们想将对象的键收集起来变成一个“字面量联合类型”,以便在后续做严格校验或自动提示;可以使用 keyof typeof someObject

const MESSAGES = {
  hello: 'Hello',
  goodbye: 'Goodbye',
  thankYou: 'Thank you'
} as const

// 生成一个类型:'hello' | 'goodbye' | 'thankYou'
type MessageKey = keyof typeof MESSAGES

function getMessage(key: MessageKey): string {
  return MESSAGES[key]
}

getMessage('hello') // OK
// getMessage('welcome'); // Error: 没有 'welcome'
  • 优点:无需手写 'hello' | 'goodbye' | 'thankYou',一旦对象键有变动,类型也会自动同步。
  • 广泛适用于:国际化字典错误码配置键等“对象 -> 键联合类型”场景。

2. 结合 type + “标签”字段,做更灵活的“代替枚举”方案

在很多场景下,与其使用 enum,直接用常量对象 + 字面量联合的方式可能更轻便,也不会在编译后额外生成 JavaScript 产物:

const COLOR = {
  RED: 'RED',
  GREEN: 'GREEN',
  BLUE: 'BLUE'
} as const

type Color = (typeof COLOR)[keyof typeof COLOR]
// => 'RED' | 'GREEN' | 'BLUE'

function paint(color: Color) {
  /* ... */
}

paint(COLOR.RED) // OK
// paint('PURPLE'); // Error
  • 这种方式依旧能让你在运行时获取具体值(COLOR.RED => 'RED'),在编译期拥有字面量类型 'RED' | 'GREEN' | 'BLUE'

3. 使用 interface + type 混合设计,分工明确

  • interface:适合描述“对象结构”,可在多个地方声明并自动合并,特别适合对外暴露的 API、配置对象、类的形状等。
  • type:更灵活,可以写联合、交叉、条件类型等高级用法,也能定义函数类型或复杂的泛型类型工具。

示例:

interface ApiResponse {
  data: any
  status: number
}

// 可以声明合并
interface ApiResponse {
  message?: string
}

// 高级条件处理用 type
type ExtractData<T extends ApiResponse> = T['data'] extends infer D ? D : never
  • 这样既利用了接口可声明合并的特性,又在需要条件/高级泛型时用 type 发挥灵活性。
  • 大多数团队的最佳实践也是“基础对象结构 -> interface,高级操作 / 组合 -> type”。

4. 在大型项目里使用 pathsbaseUrl 优化导入

TypeScript 的 tsconfig.json 中,可以设置 "baseUrl""paths",让你在导入文件时使用更简洁的别名,而不是到处写相对路径 ../../...。例如:

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "src",
    "paths": {
      "@utils/*": ["utils/*"],
      "@components/*": ["components/*"],
      "@models/*": ["models/*"]
    }
  }
}
  • 这样在代码里就能写:
    import { formatDate } from '@utils/date'
    import { User } from '@models/user'
    而不是 import { formatDate } from '../../utils/date'
  • 大型项目中能极大提高可读性与维护性,但需要配合打包工具(Webpack、Vite、Rollup 等)的别名配置保持一致。

5. 使用 “内置”声明文件里已有的 JSX.IntrinsicElements 做自定义组件属性推断

当写 React 或 Vue3(JSX 方案)时,想在自定义组件上也有类似原生标签 div 一样的属性推断,可以在类型层面声明扩充 JSX.IntrinsicElements。比如自定义 <MyButton>

declare global {
  namespace JSX {
    interface IntrinsicElements {
      // 声明一个 my-button 元素,对应 React/Vue3 虚拟 DOM 中可用
      'my-button': React.DetailedHTMLProps<
        React.ButtonHTMLAttributes<HTMLButtonElement>,
        HTMLButtonElement
      >
    }
  }
}

function App() {
  return <my-button onClick={() => alert('Click!')}>Click me</my-button>
}
  • 这样 TS 会自动推断 onClickdisabled 等按钮属性。
  • 在 Vue3 + TSX 场景也有类似思路,或者使用 vue-tsx-types 等辅助库。

8. 在单元测试中使用“类型断言 + 类型谓词”简化测试代码

有时我们写单元测试(特别是对库/工具函数的测试)需要直接断言类型是否正确,可以通过类型断言 + dummy 变量 + 类型谓词来巧妙验证。例如:

function expectType<T>(value: T) {
  // 空函数,只靠编译时检查
}

function testSomeUtility() {
  const result = someUtility('hello')
  // 期望 result 是 number
  expectType<number>(result)
}
  • expectType<T>(value: T) 的作用仅在编译期,在运行时什么也不做,但能让我们知道 result 是否真为 number
  • 可以写一个全局的 testType 工具函数,用在测试文件里专门断言类型推断正确,不用手动在代码中写 as number

9. 使用 重载 + Rest 参数,兼容多种调用方式(可选回调、Promise 等)

在一些库函数中,想既支持“回调风格”,又支持“Promise 风格”,可以写函数重载,让调用者在 TS 层面获得正确的参数/返回值提示:

// 定义重载
function fetchData(url: string, callback: (res: string) => void): void
function fetchData(url: string): Promise<string>

// 实现
function fetchData(url: string, callback?: (res: string) => void): any {
  if (callback) {
    setTimeout(() => callback(`Data from ${url}`), 1000)
  } else {
    return new Promise<string>(resolve => {
      setTimeout(() => resolve(`Data from ${url}`), 1000)
    })
  }
}

// 使用
fetchData('api/test', res => console.log('Callback style:', res))
fetchData('api/test').then(res => console.log('Promise style:', res))
  • 在调用端,TS 会自动区分传不传 callback,从而推断出返回值是 void 还是 Promise<string>
  • 很多老式库也在逐步支持 Promise,但也要兼容 callback,因此这样的写法很常见。
    好的,这里再补充一波**“干货”,同样围绕在日常开发和团队协作中会遇到的真实场景**,包括更少人提及却很实用的细节、编译配置、调试技巧,以及一些常见的“坑”与对应的解决方案。希望对你有所启发!

1. 巧用 @ts-expect-error@ts-ignore 的差异

在需要忽略某些 TS 报错时,我们常见做法是用 // @ts-ignore。但 TypeScript 还提供了更安全的 // @ts-expect-error 注释:

  • @ts-ignore:无条件忽略下行所有 TS 报错,不会在编译时做任何检查。
  • @ts-expect-error:告诉编译器“我预期这里会有一个错误”,如果编译器发现没有错误,就会报“多余的 expect-error”警告,反过来也可以帮我们发现“修复后忘了删除的忽略注释”。
// @ts-expect-error: 我明确知道这个地方会报错,先临时绕过
const x: number = 'hello'

// @ts-ignore: 我知道这里会有类型问题,但就是要忽略
const y: string = 123
  • 最佳实践:在可控范围内,尽量使用 @ts-expect-error,这样如果不再需要忽略时能被编译器提示;不要随意滥用 @ts-ignore 让类型系统失去意义。

2. 给第三方库类型打“补丁”时,避免冲突与版本不兼容

当你需要覆盖补充第三方库的类型定义时(尤其是 DefinitelyTyped 上的 @types/xxx),最好在本地声明文件里用以下方式区分开来,避免将来库升级冲突或命名重叠:

  1. types/patches/xxx-augment.d.ts 中写 declare module 'xxx' {...}
  2. tsconfig.json"include""files" 中显式包含这些声明文件,确保它们被读取。
  3. 如果只是新增接口的属性,不会影响原有定义;如果是修改已有属性类型,需要谨慎评估冲突的风险,最好在注释中说明原因。

这样做能让你在不 fork 第三方类型仓库的前提下,灵活地修正扩展它的类型,而当库版本升级时,也比较好排查和维护。


4. 注意 strictNullChecksstrict 选项的开启

tsconfig.json 里,TypeScript 的 "strict": true(或至少 "strictNullChecks": true)可以帮助你杜绝很多潜在的 null / undefined 错误,让 TS 的类型推断更严格、更准确:

{
  "compilerOptions": {
    "strict": true,
    // 或者至少
    "strictNullChecks": true
  }
}
  • 强烈建议:在新项目中务必开启 "strict",如果是老项目,可以分步骤迁移。
  • 这不仅能让你在编译期发现更多问题,也能在编码时获得更完善的智能提示和自动补全。

5. 充分运用 noUncheckedIndexedAccess,避免数组/字典越界

TypeScript 4.1+ 新增了 noUncheckedIndexedAccess 编译选项。一旦开启,当你访问数组或对象索引时,类型会带上 | undefined,提示“可能越界或无此键”:

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}

const arr = [1, 2, 3];
const first = arr[0];   // number
const maybe = arr[99];  // number | undefined  <-- TS 提示可能越界

const obj: Record<string, number> = {};
const value = obj['someKey'];  // number | undefined
  • 好处:防止无意的下标越界、字典缺省访问等问题。
  • 坏处:对一些场景会多出较多 undefined 判断,需要与业务逻辑匹配。若团队能接受,开启后能显著提升安全性。

6. 使用 type-only import/export:让编译产物更干净

在 TypeScript 3.8+ 中,出现了 import typeexport type 这两个新语法,它们仅在编译期保留类型信息,而不会在编译后的 JavaScript 产生额外的 import / export 代码。示例:

// 原本:
// import { User } from './types';
// 在编译后(可能为 .js)也会生成对应的 import

// 现在可以:
import type { User } from './types'

export type { User, Order }

// 这样在编译产物中不会出现多余的 runtime import
  • 纯类型引用使用 import type 可减少编译后不必要的 import 语句、提升清晰度。
  • 库开发大型项目里,会让输出的 .js 更干净,避免无意义的 import/export。

7. 避免“命名冲突”与“默认导出 + 命名导出”混乱

在实践中,由于 TypeScript 对默认导出export default)与命名导出export { ... })都支持,常会出现命名冲突引用混乱等问题。常见建议:

  1. 优先使用命名导出而非默认导出,这样在重构或搜索时更直观。
  2. 如果一定要默认导出,可以将文件名与默认导出的实体保持一致,以减少认知负担,比如 user-service.tsexport default class UserService.
  3. 引入时注意区分:
    import MyComponent from './MyComponent' // default
    import { MyComponentProps } from './MyComponent' // named
    如果两者同名,很容易让代码阅读者或自己迷惑。

8. 用 esModuleInteropallowSyntheticDefaultImports 处理 CommonJS 模块兼容

有时需要引入采用 CommonJS 规范的库(例如老版本的 module.exports = ...),在 TypeScript 中若想直接 import x from 'xxx',需要在 tsconfig.json 中设置以下选项,确保 TS 能正确编译、保留默认导出语义:

{
  "compilerOptions": {
    "module": "commonjs",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true
  }
}
  • esModuleInterop 会帮你自动为 CommonJS 导出添加默认导出兼容;
  • allowSyntheticDefaultImports 则允许你使用 import defaultExport from 'module-name' 即使其实它没有真的默认导出。

如果不配置,在引入 CommonJS 库时需要用 import * as x from 'xxx'const x = require('xxx') 来避免编译错误。
好的,再为你补充一批**“高级而又实用”的 TypeScript 技巧/知识点,重点聚焦在较新版本**(4.x 到 5.x)的改进或常被忽略却能大幅提升开发体验的点,帮助你与时俱进地发挥 TypeScript 的威力。


2. TS 5.0 “稳定版装饰器(Decorators)”与元数据

TypeScript 5.0 将装饰器(Decorators)升级为稳定语法(之前是实验特性),并借鉴了“ECMAScript decorators”提案的一些变更。它支持对类、方法、访问器、属性、参数进行声明式的“扩展”。在TS 5.0中:

function Log(target: any, context: ClassDecoratorContext) {
  console.log(`Decorating class: ${context.name}`)
}

@Log
class Person {
  name: string
  constructor(name: string) {
    this.name = name
  }
}
  • 与之前实验版的 experimentalDecorators 语法略有区别,需要你查看新的 Decorators 规范。
  • 如果你在写NestJSAngular 或自定义脚手架/框架,新的“稳定版”装饰器可以更好地与未来标准对接。

3. “模块后缀” (moduleSuffixes) 配置,兼容 .js / .mts / .cts

TS 4.7 起,Node.js ES Module 环境中需要区分后缀(如 .js.mjs.cjs 等)。tsconfig.json 提供了 "moduleSuffixes" 选项,可以用来兼容不同后缀的导入方式。例如:

{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "node",
    "moduleSuffixes": [".js", ".ts", ".d.ts"]
  }
}
  • 这样,当你的源文件是 .ts,引入却是 ./foo.js 时,编译器也能正确解析到 ./foo.ts
  • 对使用 ESM + TS 的项目,这能大大缓解“找不到模块”的报错。

4. verbatimModuleSyntax(TS 5.2+)保留导入导出语句原样

TypeScript 5.2 新增了 verbatimModuleSyntax 试验性配置,让编译器在转译时保留原始的 import/export 路径、语法,不做额外的拓展或后缀处理(前提是你使用的是 target: "ESNext" 之类)。好处:

  • 如果你需要在最终产物中保留精确的 ESM 样式以配合 bundler 或 loader 进行按需处理,这个选项很有用。
  • 适合在不需要编译器过多干预模块导入导出的场景下使用(尤其是想“保留所有 import/export,交给后续工具处理”)。

5. 改善 watch 模式incremental + watch + skipLibCheck

对于大中型项目,开启以下配置可以显著加快编译和增量构建速度:

{
  "compilerOptions": {
    "incremental": true,
    // 如果第三方声明文件质量不错,可考虑跳过检查
    "skipLibCheck": true
    // ...
  },
  "watchOptions": {
    // TS 4.x+ 提供的一些 watch 选项
    "watchFile": "useFsEvents",
    "watchDirectory": "useFsEvents",
    "fallbackPolling": "dynamicPriority"
  }
}
  • incremental:让 TS 生成 .tsbuildinfo 文件,后续只重新编译改动过的部分。
  • skipLibCheck:跳过对 node_modules 中声明文件的严格检查,一般不会影响你项目的类型安全,却能节省大量编译时间。
  • watchOptions:在大项目里,用事件监控 (useFsEvents) 而非轮询,可以减少 CPU 占用。

8. 泛型工具:基于 extends keyof any / PropertyKey 做灵活键类型

当我们需要声明“某个键必须是字符串/数字/符号”时,可以用 TS 的 keyof anyPropertyKey(二者同义)来表示可当键的联合类型:

type Dictionary<T> = {
  [K in PropertyKey]: T
}

// or
type Dictionary<T> = {
  [K in keyof any]: T // string | number | symbol
}

const dict: Dictionary<number> = {
  foo: 1,
  123: 2,
  [Symbol('xyz')]: 3
}
  • 适合做某些动态类型的对象映射,比如 Redux store 里的按 Key 存放组件状态等等。

10. 可选属性的“精确性”exactOptionalPropertyTypes(TS 4.4+)

exactOptionalPropertyTypes 可以让 TS 更精准地区分“确实存在但为 undefined”与“属性根本没定义”。开启后,? 属性就真的表示“此属性可能不存在”,而不是“存在且可以是 undefined”。示例:

{
  "compilerOptions": {
    "exactOptionalPropertyTypes": true
  }
}
interface Test {
  foo?: string // 要么完全没有 'foo',要么有 'foo' 并且是 string
}

const t1: Test = {}
const t2: Test = { foo: undefined }
// Error: foo 不可为 undefined,除非你写 `foo?: string | undefined`
  • 这能更严格地防范一些“属性存在但值是 undefined”的误用,也让你的接口定义更贴近实际语义。
  • 如果想兼容以前的宽松写法,需要在属性类型里手动加 | undefined
    好的,再补充一批更“高阶”或“深度”一点的 TypeScript 技巧和思路,涵盖一些编译性能优化类型推断边界工具链集成少见却实用的场景,希望对你有帮助。

5. 让“never”做穷尽性校验还能检测联邦数据流遗漏

我们常在 switch 语句或reducers里,用“never 变量”来检查是否处理完了所有联合类型成员:

type Action =
  | { type: 'increment'; payload: number }
  | { type: 'decrement'; payload: number }
  | { type: 'reset' }

function reducer(action: Action) {
  switch (action.type) {
    case 'increment':
      return action.payload + 1
    case 'decrement':
      return action.payload - 1
    case 'reset':
      return 0
    default:
      const exhaustiveCheck: never = action
      // 如果 Action 后续新增了 'something',这里会报错
      return exhaustiveCheck
  }
}
  • 如果某天给 Action 新增了 'toggle' 而忘了在 switch 中处理,编译器就会在 exhaustiveCheck 这一行报错,提醒开发者补齐分支。
  • Redux状态机协议处理等特别好用。

6. 同时使用多种 npm scripts + tsc + Bundler 时的工程结构

前后端多包结构(monorepo)项目里,有时需要多套 tsconfig 配置:

  • 一个可能是针对“主业务”编译,
  • 一个用于生成类型声明文件,
  • 一个专门给测试环境/脚本使用(可能 target 不同)。

例如:

/tsconfig.json          -- 基础配置
/tsconfig.build.json    -- 用于打包构建
/tsconfig.types.json    -- 用于单独 emitDeclarationOnly
/tsconfig.test.json     -- 用于单元测试

然后在 package.jsonscripts 中,根据需要不同 tsconfig 执行:

{
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "types": "tsc --emitDeclarationOnly -p tsconfig.types.json",
    "test": "tsc -p tsconfig.test.json && jest"
  }
}
  • 这种多 tsconfig方案能更精准地分别满足不同环境/需求(构建产物、类型声明、测试编译等),避免在一个 tsconfig 里过度折衷。

7. 巧妙使用 “类型层面的依赖注入” 或 “层级推断”

在一些依赖注入(DI)或插件式场景中,我们可以用泛型的方式让每个插件在类型层面“注入”新的特性。例如:

interface Context {
  // 基础上下文
}

interface Plugin<T> {
  // 每个插件都返回一个“增强后的上下文”
  (ctx: T): T & { [extraProps: string]: any }
}

function applyPlugins<T>(ctx: T, plugins: Array<Plugin<T>>): T {
  return plugins.reduce((acc, plugin) => plugin(acc), ctx)
}
  • 如果想更精细地给上下文新增属性,还可以结合交叉类型映射类型做出更完整的“类型级合并”。
  • 这样在写中间件/插件时就能同时“接收上下文”并“增强上下文”,在后续使用时类型会越来越丰富。

8. 用 “参数属性” 写法 简化类构造

TypeScript 提供的“参数属性” (Parameter Properties) 语法,可以让我们在构造函数参数中直接声明 public / private / readonly 等,避免冗长的重复声明:

class Person {
  constructor(
    public name: string,
    private readonly age: number
  ) {
    // 不用再写 this.name = name; this.age = age
  }

  greet() {
    console.log(`Hi, I'm ${this.name}, and I'm ${this.age} years old`)
  }
}

const p = new Person('Alice', 30)
// p.name => 'Alice'
// p.age 只读,且外部不可直接访问 (private)
  • 在大型 OOP 代码中,能显著减少样板代码;
  • 与传统写法行为一致,只是更加简洁。

9. “绝对路径” vs “相对路径” vs “包内导入” 三选一的团队规范

在团队里,引用其他模块常见方式有:

  1. 相对路径: import ../utils/tool
  2. 绝对路径(配合 baseUrl): import utils from 'src/utils'
  3. 包模式(配合 "paths" 或 monorepo 的 package.json name): import { something } from '@myorg/utils'

建议:选定一种适合团队和项目结构的方式,并在 tsconfig.json 里配置好 "baseUrl""paths".

  • 若是monorepo,可在每个子包的 package.json 里设 "name": "@myorg/packageA", 并将其链接到根下做包内导入
  • 若是单体项目,可以用 "baseUrl": "src" + "paths" 来避免深层相对路径嵌套。
  • 写入到团队lint 规则代码规范中,长期保持一致,利于维护。

10. 处理“第三方类型”不完美时的“前置断言”/“后置断言**”

有时第三方库的类型定义可能不够精确,比如声明返回值过于宽泛 (any / unknown 等),我们可以在调用处先做断言或封装成一个更精准的函数。例如:

declare function thirdPartyGetData(): any // 第三方声明得比较糟

function getMyData(): { user: string; count: number } {
  const raw = thirdPartyGetData()
  // 在此处我们可以通过 runtime check/类型断言保证返回
  if (typeof raw?.user === 'string' && typeof raw?.count === 'number') {
    return raw
  }
  throw new Error('Invalid data')
}
  • 封装之后,我们项目里就都用 getMyData(),避免在任何地方都要写 as { ... }.
  • 或者通过模块扩展(module augmentation) 修改第三方库类型,但那要确保真实返回值确实符合,否则容易埋雷。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant