相关文章推荐
强悍的海龟  ·  jsx/tsx使用cssModule和typ ...·  2 周前    · 
虚心的豌豆  ·  TypeScript ...·  2 周前    · 
玩足球的闹钟  ·  在 alpine ...·  1 年前    · 
TypeScript 接口合并, 你不知道的妙用

TypeScript 接口合并, 你不知道的妙用

初识

声明合并(Declaration Merging) Typescript 的一个高级特性,顾名思义, 声明合并 就是将相同名称的一个或多个声明合并为单个定义。

例如:

interface Box {
  height: number;
  width: number;
interface Box {
  scale: number;
let box: Box = { height: 5, width: 6, scale: 10 };
interface Cloner {
  clone(animal: Animal): Animal;
interface Cloner {
  clone(animal: Sheep): Sheep;
interface Cloner {
  clone(animal: Dog): Dog;
  clone(animal: Cat): Cat;
// Cloner 将合并为
//interface Cloner {
//  clone(animal: Dog): Dog;
//  clone(animal: Cat): Cat;
//  clone(animal: Sheep): Sheep;
//  clone(animal: Animal): Animal;


声明合并最初的设计目的是为了解决早期 JavaScript 模块化开发中的类型定义问题。

  • 早期的 JavaScript 库基本都使用全局的 命名空间 ,比如 jQuery 使用 $ , lodash 使用 _ 。这些库通常还允许对命名空间进行扩展,比如 jQuery 很多插件就是扩展 $ 的原型方法
  • 早期很多 Javascript 库也会去扩展或覆盖 JavaScript 内置对象的原型。比如古早的 RxJS 就会去 「Monkey Patching」 JavaScript 的 Array、Function 等内置原型对象。


尽管这些方案在当今已经属于「反模式」了,但是在 Typescript 2012 年发布那个年代, jQuery 还是王者。


Typescript 通过类型合并这种机制,支持将分散到不同的文件中的命名空间的类型定义合并起来,避免编译错误。

现在是 ES Module 当道, 命名空间的模式已经不再流行。但是不妨碍 声明合并 继续发光发热,本文就讲讲它几个有趣的使用场景。


JSX 内置组件声明

Typescript 下,内置的组件( Host Components ) 都挂载在 JSX 命名空间下的 IntrinsicElements 接口中。例如 Vue 的 JSX 声明:

// somehow we have to copy=pase the jsx-runtime types here to make TypeScript happy
import type {
  VNode,
  IntrinsicElementAttributes,
  ReservedProps,
  NativeElements
} from '@vue/runtime-dom'
//   全局作用域
declare global {
  namespace JSX {
    export interface Element extends VNode {}
    export interface ElementClass {
      $props: {}
    export interface ElementAttributesProperty {
      $props: {}
    //   内置组件定义
    export interface IntrinsicElements extends NativeElements {
      // allow arbitrary elements
      // @ts-ignore suppress ts:2374 = Duplicate string index signature.
      [name: string]: any
    export interface IntrinsicAttributes extends ReservedProps {}


我们也可以随意地扩展 IntrinsicElements,举个例子,我们开发了一些 Web Component 组件:

declare global {
  namespace JSX {
    export interface IntrinsicElements {
      'wkc-header': {
        // props 定义
        title?: string;


上面例子中 JSX 是放在 global 空间下的,某些极端的场景下,比如有多个库都扩展了它,或者你即用了 Vue 又用了 React, 那么就会互相污染。 现在 Typescript 也支持 JSX 定义的局部化,配合 jsxImportSource 选项来开启, 参考 Vue 的实现


Vue 全局组件声明

和 JSX 类似, Vue 全局组件、全局属性等声明也通过接口合并来实现。下面是 vue-router 的代码示例:

declare module '@vue/runtime-core' {
  // Optional API 扩展
  export interface ComponentCustomOptions {
    beforeRouteEnter?: TypesConfig extends Record<'beforeRouteEnter', infer T>
      : NavigationGuardWithThis<undefined>
    beforeRouteUpdate?: TypesConfig extends Record<'beforeRouteUpdate', infer T>
      : NavigationGuard
    beforeRouteLeave?: TypesConfig extends Record<'beforeRouteLeave', infer T>
      : NavigationGuard
  // 组件实例属性
  export interface ComponentCustomProperties {
    $route: TypesConfig extends Record<'$route', infer T>
      : RouteLocationNormalizedLoaded
    $router: TypesConfig extends Record<'$router', infer T> ? T : Router
  //   全局组件
  export interface GlobalComponents {
    RouterView: TypesConfig extends Record<'RouterView', infer T>
      : typeof RouterView
    RouterLink: TypesConfig extends Record<'RouterLink', infer T>
      : typeof RouterLink


上面我们见识了 JSX 使用 declare global 来挂载 全局作用域 ,而 declare module * 则可以挂载到 具体模块的作用域 中。


另外,我们在定义 Vue Route 时,通常会使用 meta 来定义一些路由元数据,比如标题、权限信息等, 也可以通过上面的方式来实现:


declare module 'vue-router' {
  interface RouteMeta {
     * 是否显示面包屑, 默认 false
    breadcrumb?: boolean
    title?: string
     * 所需权限
    permissions?: string[]


export const routes: RouteRecordRaw[] = [ 
    path: '/club/plugins',
    name: 'custom-club-plugins',
    component: () => import('./plugins'),
    // 现在 meta 就支持类型检查了
    meta: {
      breadcrumb: true,
  // ...


依赖注入:实现标识符和类型信息绑定

还有一个比较有趣的使用场景,即依赖注入。我们在使用 InversifyJS 这里依赖注入库时,通常都会使用字符串或者 Symbol 来作为依赖注入的 标识符

// inversify 示例
// 定义标识符
const TYPES = {
    Warrior: Symbol.for("Warrior"),
    Weapon: Symbol.for("Weapon"),
    ThrowableWeapon: Symbol.for("ThrowableWeapon")
@injectable()
class Ninja implements Warrior {
    @inject(TYPES.Weapon) private _katana: Weapon;
    @inject(TYPES.ThrowableWeapon) private _shuriken: ThrowableWeapon;
    public fight() { return this._katana.hit(); }
    public sneak() { return this._shuriken.throw(); }


但是这种标识符没有关联任何类型信息,无法进行类型检查和推断。


于是,笔者就想到了 接口合并 。能不能利用它来实现标识符和类型之间的绑定?答案是可以的:

我们可以声明一个全局的 DIMapper 接口。这个接口的 key 为依赖注入的标识符,value 为依赖注入绑定的类型信息。

declare global {
  interface DIMapper {}


接下来,依赖注入的『供应商』,就可以用来声明标识符和注入类型的绑定关系:

interface IPhone {
   * 打电话
  call(num: string): void
   * 发短信
  sendMessage(num: string, message: string): void
// 表示 DI.IPhone 这个标识符关联的就是 IPhone 接口类型
declare global {
  interface DIMapper {
    'DI.IPhone': IPhone


我们稍微改造一下依赖注入相关方法的实现:

/**
 * 获取所有依赖注入标识符
export type DIIdentifier = keyof DIMapper;
 * 计算依赖注入值类型
export type DIValue<T extends DIIdentifier> = DIMapper[T];
 * 注册依赖
export function registerClass<I extends DIIdentifier, T extends DIValue<I>>(
  identifier: I,
  target: new (...args: never[]) => T,
): void
 * 获取依赖
export function useInject<I extends DIIdentifier, T extends DIValue<I>>(
  identifier: I,
  defaultValue?: T,
): T


使用方法:

class Foo {}
class MI {
  call(num: string) {}
  sendMessage(num: string, message: string) {}
registerClass('DI.IPhone', Foo) // ❌ 这个会报错,Foo 不符合 IPhone 接口
registerClass('DI.IPhone', MI) // ✅ OK!
const phone = useInject('DI.IPhone') // phone 自动推断为 IPhone 类型
对于依赖注入,我在 全新 JavaScript 装饰器实战下篇:实现依赖注入 , 介绍了另外一种更加严格和友好的方式。


事件订阅

同样的办法也可以用于 事件订阅

declare global {
   * 声明 事件 标识符和类型的映射关系
   * @example 扩展定义
   * declare global {
   *   interface EventMapper {
   *     'Event.foo.success': ISuccessMessage
   *   }
  interface EventMapper {}
 * 事件名称
export type EventName = keyof EventMapper;
 * 事件参数
export type EventArgument<T extends EventName> = EventMapper[T];


EventBus 实现:

export class EventBus {
   * 监听事件
  on<N extends EventName, A extends EventArgument<N>>(event: N, callback: (arg: A) => void) {}
   * 触发事件
  emit<N extends EventName, A extends EventArgument<N>>(event: N, arg: A) {}


动态类型插槽

还有一个比较脑洞的例子,我之前封装过一个 Vue i18n 库,因为 Vue 2/3 差异有点大,所以我就拆了两个库来实现,如下图。 i18n 用于 Vue 3 + vue-i18n@>=9 , i18n-legacy 用于 Vue 2 + vue-i18n@8

但是两个库大部分的实现是一致的,这些共性部分就提取到 i18n-shared



然而 i18n-shared 并不耦合 Vue vue-i18n 的版本,也不可能将它们声明为依赖项, 那么它相关 API 的类型怎么办呢?

// i18n-shared 代码片段
export interface I18nInstance {
   * vue 插件安装
   *   VueApp 是 Vue App 的实例
  install(app: VueApp): void;
  //   vue-i18n 的实例
  i18n: VueI18nInstance;
  // ...
 * 获取全局实例
 * @returns
export function getGlobalInstance(): I18nInstance {
  if (globalInstance == null) {
    throw new Error(`请先使用 createI18n 创建实例`);
  return globalInstance;
 * 获取全局 vue i18n 实例
export function getGlobalI18n(): I18nInstance['i18n'] {
  return getGlobalInstance().i18n;


这里用 泛型 也解决不了问题。

一些奇巧淫技还得是类型合并。我在这里就巧妙地使用了类型合并来创建 类型插槽。

首先在 i18n-shared 下预定义一个接口:

/**
 *   供子模块详细定义类型参数
export interface I18nSharedTypeParams {
  // VueI18nInstance: vue i18n 实例类型
  // FallbackLocale
  // VueApp 应用类型
// 提取参数
// @ts-expect-error
type ExtraParams<T, V = I18nSharedTypeParams[T]> = V;
export type VueApp = ExtraParams<'VueApp'>;
export type VueI18nInstance = ExtraParams<'VueI18nInstance'>;


定义了一个接口 I18nSharedTypeParams 它具体的类型由下级的库来注入 ,我尚且把它命名为 “ 动态类型插槽 ” 吧。

现在 i18n i18n-legacy 就可以根据自己的依赖环境来配置它了:


i18n-legacy:

import VueI18n from 'vue-i18n'; // vue-i18n@8
import Vue from 'vue'; // vue@2
declare module 'i18n-shared' {
  export interface I18nSharedTypeParams {
    VueI18nInstance: VueI18n;
    VueApp: typeof Vue;


i18n:

import { VueI18n, Composer } from 'vue-i18n'; // vue-i18n@9+
import { App } from 'vue'; // vue@3
declare module 'i18n-shared' {