Extending TypeScript Type Definitions

September 11th 2020 TypeScript Vue.js

Due to the dynamic nature of JavaScript, some libraries encourage you to extend their default types. An example of that is Vue.js. Its plugins often extend the Vue prototype that's available in every component. How can this best be handled with TypeScript?

The easiest way to learn is to look at the plugins that already do this. The official Vue Router is such a plugin with full TypeScript support. It extends the Vue prototype with $route and $router members to make them available inside the components:

import Vue from "vue";
import Component from "vue-class-component";

@Component({})
export default class extends Vue {
  goBack() {
    window.history.length > 0 ? this.$router.go(-1) : this.$router.push("/");
  }
}

The prototype is still imported from the vue package but it includes full type information about the members added by the Vue Router plugin. How is this possible? The answer is in the types/vue.d.ts file in the vue-router package:

declare module "vue/types/vue" {
  interface Vue {
    $router: VueRouter;
    $route: Route;
  }
}

The same pattern can be used for your plugins. And even for third-party plugins without TypeScript to make them more convenient to use with TypeScript.

The Elastic APM Vue package is an example of such a plugin. The documentation instructs you to install it in the main.ts file:

import router from "./router";
import { ApmVuePlugin } from "@elastic/apm-rum-vue";

Vue.config.productionTip = false;

Vue.use(ApmVuePlugin, {
  router,
  config: {
    serviceName: "app-name",
  },
});

However, the TypeScript compiler will throw the following error:

Could not find a declaration file for module @elastic/apm-rum-vue. node_modules/@elastic/apm-rum-vue/dist/lib/index.js implicitly has an any type.

Try npm install @types/elastic__apm-rum-vue if it exists or add a new declaration (.d.ts) file containing declare module '@elastic/apm-rum-vue';

Since there's no @types/elastic__apm-rum-vue package available, a local type declaration must be created to resolve the issue. I decided to put it in types/elastic__apm-rum-vue/index.d.ts:

declare module "@elastic/apm-rum-vue";

I also had to modify the tsconfig.json file to tell the compiler about this new type declaration:

{
  "compilerOptions": {
    // ...
    "types": ["webpack-env", "types/elastic__apm-rum-vue"]
    // ...
  },
  "include": [
    "types/**/*.d.ts"
    // ...
  ]
  // ...
}

Only now, the issue with extending the Vue prototype becomes apparent:

import Vue from "vue";
import Component from "vue-class-component";

@Component({
  created(this: About) {
    this.span = this.$apm.startSpan("create-mount-duration", "custom");
  },
  mounted(this: About) {
    this.span?.end();
  },
})
export default class About extends Vue {
  span?: Span;
}

The compiler knows nothing about the $apm member that was added by the plugin (nor about the Span type for that matter):

Property $apm does not exist on type About.

Cannot find name Span.

The issue can be resolved by following the approach from the Vue Router plugin:

import { ApmBase } from "@elastic/apm-rum";

declare module "vue/types/vue" {
  interface Vue {
    readonly $apm: ApmBase;
  }
}

I put the code above in src/vue.d.ts. I also took advantage of the fact that the core Elastic APM package comes with TypeScript support. This means that instead of only fixing the build problem I also got full type definitions for the new $apm prototype member (including the Span type).

A working sample project featuring this approach is available from my GitHub repository.

A TypeScript type definition can be extended in a different type definition file by declaring a module matching the original location of the type definition (vue/types/vue matches the vue.d.ts file in the types folder of the vue NPM package). Any members declared in a type will be added to the members declared in the original type definition.

Get notified when a new blog post is published (usually every Friday):

Copyright
Creative Commons License