Create a TypeScript library with optional dependencies resolved by application

1.3k Views Asked by At

I've written a library published to a private npm repo which is used by my applications. This library contains utilities and has dependencies to other libraries, as an example let's choose @aws-sdk/client-lambda.

Some of my applications use only some of the utilities and don't need the dependencies to the external libraries, while some applications use all of the utilities.

To avoid having all applications getting a lot of indirect dependencies they don't need, I tried declaring the dependencies as peerDependencies and having the applications resolve the ones they need. It works well to publish the package, and to use it from applications who declare all of the peerDependencies as their own local dependencies, but applications failing to declare one of the dependencies get build errors when the included .d.ts files of the library are imported in application code:

error TS2307: Cannot find module '@aws-sdk/client-kms' or its corresponding type declarations.

Is it possible to resolve this situation so that my library can contain many different utils but the applications may "cherry-pick" the dependencies they need to fulfill the requirements of those utilities in runtime? Do I have to use dynamic imports to do this or is there another way?

I tried using @ts-ignore in the library code, and it was propagated to the d.ts file imported by the applications, but it did not help.

Setup:

my-library

package.json:

peerDependencies: {
  "@aws-sdk/client-lambda": "^3.27.0"
}

foo.ts:

import {Lambda} from '@aws-sdk/client-lambda';

export function foo(lambda: Lambda): void {
  ...
}

bar.ts:

export function bar(): void {
  ...
}

index.ts:

export * from './foo';
export * from './bar';

my-application1 - works fine

package.json:

dependencies: {
  "my-library": "1.0.0",
  "@aws-sdk/client-lambda": "^3.27.0" 
}

test.ts:

import {foo} from 'my-library';

foo();

my-application2 - does not compile

package.json:

dependencies: {
  "my-library": ...
}

test:ts:

import {bar} from 'my-library';

bar();
2

There are 2 best solutions below

0
On

I found two ways of dealing with this:

1. Only use dynamic imports for the optional dependencies

If you make sure that types exported by the root file of the package only include types and interfaces and not classes etc, the transpiled JS will not contain any require statement to the optional library. Then use dynamic imports to import the optional library from a function so that they are required only when the client explicitly uses those parts of the library. In the case of @aws-sdk/client-lambda, which was one of my optional dependencies, I wanted to expose function that could take an instance of a Lambda object or create one itself:

import {Lambda} from '@aws-sdk/client-lambda';

export function foo(options: {lambda?: Lambda}) { 
  if (!lambda) {
    lambda = new Lambda({ ... });  
  }
  ...
}

Since Lambda is a class, it will be part of the transpiled JS as a require statement, so this does not work as an optional dependency. So I had to 1) make that import dynamic and 2) define an interface to be used in place of Lambda in my function's arguments to get rid of the require statement on the package's root path. Unfortunately in this particular case, the AWS SDK does not offer any type or interface which the class implements, so I had to come up with a minimal type such as

export interface AwsClient {
  config: {
    apiVersion: string;
  }
}

... but of course, lacking a type ot represent the Lambda class, you might even resort to any.

Then comes the dynamic import part:

export async function foo(options: {lambda?: AwsClient}) { 
  if (!lambda) {
    const {Lambda} = await import('@aws-sdk/client-lambda');
    lambda = new Lambda({ ... });  
  }
  ...
}

With this code, there is no longer any require('@aws-sdk/client-lambda') on the root path of the package, only within the foo function. Only clients calling the foo function will have to have the dependency in their node_modules.

As you can see, a side-effect of this is that every function using the optional library must be async since dynamic imports return promises. In my case this worked out, but it may complicate things. In one case I had a non-async function (such as a class constructor) needing an optional library, so I had no choice but to cache the promised import and resolve it later when used from an async member function, or do a lazy import when needed. This has the potential of cluttering code badly ...

So, to summarize:

  • Make sure any code that imports code from the optional library is put inside functions that the client wanting to use that functionality calls
  • It's OK to have imports of types from the optional library in the root of your package as it's stripped out when transpiled
  • If needed, defined substitute types to act as place-holders for any class arguments (as classes are both types and code!)
  • Transpile and investigate the resulting JS to see if you have any require statement for the optional library in the root, if so, you've missed something.

Note that if using webpack etc, using dynamic imports can be tricky as well. If the import paths are constants, it usually works, but building the path dynamically (await import('@aws-sdk/' + clientName)) will usually not unless you give webpack hints. This had me puzzled for a while since I wrote a wrapper in front of my optional AWS dependencies, which ended up not working at all for this reason.

2. Put the files using the optional dependencies in .ts files not exported by the root file of the package (i.e., index.ts).

This means that clients wanting to use the optional functionality must import those files by sub-path, such as:

import {OptionalStuff} from 'my-library/dist/optional;

... which is obviously less than ideal.

0
On

in my case, the typescript IDE in vscode fails to import the optional type, so im using the relative import path

// fix: Cannot find module 'windows-process-tree' or its corresponding type declarations
//import type * as WindowsProcessTree from 'windows-process-tree';
import type * as WindowsProcessTree from '../../../../../node_modules/@types/windows-process-tree';

// global variable
let windowsProcessTree: typeof WindowsProcessTree;

if (true) { // some condition
  windowsProcessTree = await import('windows-process-tree');
  windowsProcessTree.getProcessTree(rootProcessId, tree => {
    // ...
  });
}

package.json

{
  "devDependencies": {
    "@types/windows-process-tree": "^0.2.0",
  },
  "optionalDependencies": {
    "windows-process-tree": "^0.3.4"
  }
}

based on vscode/src/vs/platform/terminal/node/windowsShellHelper.ts