19

When publishing a TypeScript package to npm that provides a function that accepts input from either one peer dependency or another, how do I define the optional peer dependencies?

import { ExternalFoo } from 'foo';
import { ExternalBar } from 'bar';

export const customPackage = (source: ExternalFoo | ExternalBar) => {
    /* ... */
}

How do I prevent people using my package from getting errors when one of the two options is missing?

Paul Gerarts
  • 1,197
  • 1
  • 8
  • 10

5 Answers5

14

Since Typescript 3.8 you can use the following syntax:

import type { ExternalFoo } from "foo";

So if you're just using that library for type information, you probably don't have to list it as a dependency or optionalDependency anymore. You might prefer to leave it as a peerDependency so that if your users will have those dependencies, they'll be on versions compatible with your library. Naturally, adding as devDependency is useful too.

The import will live on the generated d.ts files only, but not on .js transpiled code. One problem, though, is that if users don't have that library installed, that type becomes any and this might mess up your own typing a bit. For example, if foo is not installed your function becomes

customPackage = (source: any | ExternalBar) =>
// equivalent to customPackage = (source: any) =>

And for this particular type annotation, it sucks because even if I have bar installed, it won't be used in that type checking. So there is a way to not depend on that library anymore, but it doesn't solve the hardship of writing type annotations that won't break down if that type is not there.

Edit: please take a look at this answer on how to deal with missing types.

Reference

villasv
  • 6,304
  • 2
  • 44
  • 78
  • 3
    It's important to note that this syntax *will* cause errors if the consuming code doesn't specify the `skipLibCheck` TS compiler option. – Coderer Nov 24 '21 at 10:13
  • 4
    Update: if you don't want to force consumers to use this option, you can put `// @ts-ignore` immediately before the `import type` line. Consumers that have `foo` will see `ExternalFoo`, and those that don't will see `any`. (I'm embarassed: I never scrolled down to the last answer to see this. Sorry!) – Coderer Nov 24 '21 at 12:34
5

Your situation is a case that currently TypeScript does not support well.

Let's first summarize your situation:

  1. foo and bar is your optional dependency, meaning you expect your consumer will use one of them along with your library.
  2. You are only using the type information from those libraries, meaning you don't have any code dependency and don't want to add them as dependencies in your package.json
  3. your customPackage function is public.

Because of point 3, you need to include the type in your library typings, meaning you need to add foo and bar as dependencies. This contradicts with point 1 and 2.

If the typings of foo and bar comes from DefinitelyTyped (i.e. from package @types/foo and @types/bar), then you can add them as your dependencies in package.json. That will solve the problem.

If the typings of foo and bar are distributed with the libraries themselves, you have to either include the libraries as dependencies (which you don't want), or create a replicate of the types ExternalFoo and ExternalBar yourself.

This means you will cut yourself off from depending on foo and bar.

Another way is looking at your library closely and sees if there is any harm in including the foo and bar as dependencies. Depending on the nature of your library, it might be not as bad as you think.

Personally, I typically will go for declaring the types myself. JavaScript is a dynamic langauge to begin with.

unional
  • 14,651
  • 5
  • 32
  • 56
  • I agree that declaring your own types is the right way to do it. This seems like a good use-case for declaring an `interface` that fits both `ExternalFoo` and `ExternalBar`, which might allow users to figure out what they want to provide (including mocked versions for testing) – River Tam Dec 03 '19 at 14:19
5

Combining all the current answers, here's the best solution I've found for current versions of TypeScript (as of late 2021):

// @ts-ignore -- optional interface, will gracefully degrade to `any` if `foo` isn't installed
import type { Foo } from "foo";
import type { Bar } from "bar";

// Equates to `Bar` when `foo` isn't installed, `Foo | Bar` when it is
type Argument = any extends Foo ? Bar : (Foo | Bar);

export function customPackage(source: Argument): void {
  ...
}

You can try it out yourself. If foo is installed, the exported method will take Foo or Bar arguments, and if it isn't, it will only accept Bar (not any).

Coderer
  • 25,844
  • 28
  • 99
  • 154
  • 1
    Not sure if this really works. I've created a library that optionally uses another library, and declared it in `peerDependencies`. When my application includes my library without that dependency, I cannot build my application as the `.d.ts` file in `node_modules/my-library/some-file.d.ts` will produce `TS2307: Cannot find module`. Is there any way to solve this without resorting to `declare`:ing the external module or using dynamic imports? – JHH Dec 06 '21 at 13:50
  • 2
    So, `node_modules/my-library/some-file.d.ts` has a `ts-ignore` comment above an import statement, but you get a `TS2307` error anyway? I don't know how to sort this out in the comments but maybe you can put together a minimal repro somewhere? – Coderer Dec 06 '21 at 16:22
  • Maybe I mixed things up but I know I got a TS2307 error when processing a .d.ts file earlier. Now when trying to set up a MRE I get javascript require error when my code tries to import anything from the library - but this is obviously because my library has an `export * from ...` statement in its `index.ts` file, meaning the transpiled JS will require files that unconditionally in turn tries to require the missing dependency. – JHH Dec 07 '21 at 11:08
  • Defining classes in a library that uses functions from optional dependencies seems difficult to do with dynamic imports since it requires it to be async... :( – JHH Dec 07 '21 at 11:13
  • 1
    The context I see more commonly used is importing an interface or class "shape" from an optional dependency and then defining a function that supports arguments of that type. I have a real world example that can take an ArrayBuffer, or a Node ReadableStream that has the same data, in an environment that supports those. You don't have to import the ReadableStream constructor to be able to use an instance that you've been passed as an argument, so no problems with async imports. – Coderer Dec 08 '21 at 14:09
1

This is a complicated situation, but what I found works is adding a ts-ignore before the import of the type that might not exist in the user's environment:

// @ts-ignore
import type { Something } from "optional"
import type { SomethingElse } from "required"

Then you can still add the package to peerDependencies and peerDependenciesMeta as optional.

Haroen Viaene
  • 1,329
  • 18
  • 33
0

Background

You will always get errors when you use optional dependencies in a file that is exposed via your packages's main entrypoint (i.e. ./index.ts).

Solution

Split your code into multiple entry points. In your example, you would have one main entry point and then two optional modules FooModule and BarModule that the user has to explicitly import.

Code

This shows your package definition and the usage of the library (imports).

package.json of library

Your package.json defines multiple entry points (exports) with respective type defintions (typesVersions).

{
  "name": "my-package"
  "version": "1.0.0",
  "main": "./dist/index.js",
  "exports": {
    ".": "./dist/index.js",
    "./foo": "./dist/modules/foo/index.js",
    "./bar": "./dist/modules/bar/index.js"
  },
  "typesVersions": {
    "*": {
      "*": [
        "dist/index.d.ts"
      ],
      "foo": [
        "dist/modules/foo/index.d.ts"
      ],
      "bar": [
        "dist/modules/bar/index.d.ts"
      ]
    }
  },
}
usage of my-package in client code

As the client only imports the FooModule, no optional dependencies for the BarModule need to be installed.

import { MyPackage } from 'my-package';
import { FooModule } from 'my-package/foo';

const myPackage = new MyPackage({ module: new FooModule() })
Kim Kern
  • 54,283
  • 17
  • 197
  • 195