etarrowo
etarrowo

Reputation: 53

How to merge typescript module defintions

I have a JavaScript module that has a custom .d.ts file. Lets call the module Foobar:

foobar.js

function foobar (opts) {
  this.register = plugin => {
    plugin(this)
  }

  this.decorate = (prop, value) => {
    this[prop] = value
  }

  return this
}
export default foobar

foobar.d.ts

export interface FoobarPlugin {
  (inst: FoobarInst): void
}

export interface FoobarInst {
  register(plugin: FoobarPlugin): void
  decorate(prop: string, value: any): void
}

export default function foobar (): FoobarInst

I also have plugin:

fuzzbuzz.js

function fuzzbuzz (inst) {
  inst.decorate('fuzzbuzz', true)
}

export default fuzzbuzz

fuzzbuzz.d.ts

import { FoobarInst } from '../foobar/foobar'

export default function fuzzbuzz (inst: FoobarInst): void

I load the plugin into my module:

index.ts

import foobar from './foobar/foobar'
import fuzzbuzz from './fuzzbuzz/fuzzbuzz'

const inst = foobar()

inst.register(fuzzbuzz)

inst.fuzzbuzz // -> true

What do I need to add to fuzzbuzz.d.ts in order to update the FoobarInst type definition?

I have tried variations of:

declare module foobar {
  interface FoobarInst {
    fuzzbuzz: boolean
  }
}

My tsconfig.json looks like:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true
    "forceConsistentCasingInFileNames": true, 
    "resolveJsonModule": true,
  }
}

Thank you for any help you can provide!

Upvotes: 4

Views: 768

Answers (2)

etarrowo
etarrowo

Reputation: 53

Thank you @ford04 for a great reply. With the links provided I was able to solve my specific goal.

The important detail with my scenario is both foobar and fuzzbuzz are authored in JavaScript, and have custom .d.ts definition files. Additionally, the foobar module does not use declare module but instead an export default on the singular function the module natively exports.

The solution code is:

| - node_modules \
|   - foobar \
|     - foobar.js
|     - foobar.d.ts
|     - package.json
|   - fuzzbuzz \
|     - fuzzbuzz.js
|     - fuzzbuzz.d.ts
|     - package.json
| - index.ts
| - package.json

foobar.js

function foobar (opts) {
  this.register = plugin => {
    plugin(this)
  }

  this.decorate = (prop, value) => {
    this[prop] = value
  }

  return this
}

export default foobar

foobar.d.ts

export interface FoobarPlugin {
  (inst: FoobarInst): void
}

export interface FoobarInst {
  register(plugin: FoobarPlugin): void
  decorate(prop: string, value: any): void
}

export default function foobar (): FoobarInst

foobar/package.json

{  
  "main": "foobar.js",
  "types": "foobar.d.ts",
}

fuzzbuzz.js

function fuzzbuzz (inst) {
  inst.decorate('fuzzbuzz', true)
}

export default fuzzbuzz

fuzzbuzz.d.ts

import { FoobarInst } from 'foobar'

declare module "foobar" {
  interface FoobarInst {
    fuzzbuzz: boolean
  }
}

export default function fuzzbuzz (inst: FoobarInst): void

fuzzbuzz/package.json

{
  "main": "fuzzbuzz.js",
  "types": "fuzzbuzz.d.ts",
}

index.ts

import foobar from 'foobar'
import fuzzbuzz from 'fuzzbuzz'

const inst = foobar()

inst.register(fuzzbuzz)

inst.fuzzbuzz // -> true and no type error!

Important Details

  • Specify the type definitions in package.json

  • declare module '<library>' will merge declarations even if the <library> does not use declare module explicitly (I believe it is still considered a module by TypeScript regardless).

  • This prototype augmentation / decoration is error prone in the sense that just by importing fuzzbuzz into index.ts will cause the types to merge...even if you do not call .register(fuzzbuzz). @ford04 answer provides more context on this Therefore, it makes sense to place the type augmentation and foobar.register(fuzzbuzz) in one module, so that types and run-time code are in sync.

Upvotes: 0

ford04
ford04

Reputation: 74500

What you are looking for is module augmentation. I think, an import statement is missing in fuzzbuzz.d.ts to let TS recognize, that the given declarations extend/augment an already existing "foobar" module:

fuzzbuzz.d.ts:

import { FoobarOpts } from "foobar"; // can be any import, preferrably one of "foobar" 

declare module "foobar" {
  // your module extensions
}

Regarding plugin architecture

The plugin extensions have to be statically analyzable by the compiler. That means, you cannot make TS augment the foobar as soon as foobar.register(fuzzbuzz) is called or via dynamic import.

Instead, the module is seen as augmented, when fuzzbuzz.d.ts is included as input for the compilation either by module resolution or automatic inclusion of .ts/.d.ts files in the project directory. Therefore, it makes sense to place the type augmentation and foobar.register(fuzzbuzz) in one module, so that types and run-time code are in sync. A minimal example:

foobar.ts:

declare module "foobar" {
  // Plugin gets the options and possibly some internal "foobar" state
  type Plugin = (opts: FoobarOpts, state: {}) => void;

  interface FoobarOpts {
    foo: string;
    bar: number;
  }

  export default function foobar(opts: FoobarOpts): void;

  function register(plugin: Plugin): void;
}

my-plugin.ts:

import { register, Plugin, FoobarOpts } from "foobar";

const fuzzBuzzPlugin: Plugin = (opt: FoobarOpts, state) => {
  opt.fuzzbuzz; // fuzzbuzz available now.
};

// set type augmentation...
declare module "foobar" {
  export interface FoobarOpts {
    fuzzbuzz: boolean;
  }
}

// ... and run-time plugin extension in one module, so they go hand in hand
register(fuzzBuzzPlugin);

client.ts:

import foobar from "foobar";

foobar({ bar: 42, foo: "buh", fuzzbuzz: true }); // works with fuzzbuzz

Upvotes: 3

Related Questions