Yargs 18 Pitfalls

Upgrading Yargs from version 17 to 18 in a TypeScript project using Jest turned out to be a bit of a nightmare. The release notes for Yargs 18.0.0 include the line “yargs is now ESM first” — a seemingly small change that can cause major headaches. In this blog post, we’ll take a closer look at what that means and how to deal with it.

Pirate ship
Photo by Elena Theodoridou on Unsplash

Background

The project in question is e-invoice-eu, a TypeScript framework for electronic invoicing. It consists of a library, an API server, a commandline client, and docs.

These components are organized into a Lerna monorepo. Yargs is used by the commandline client in the workspace apps/cli. The testing framework in use throughout the monorepo is Jest.

Each workspace comes with its own tsconfig.json and a tsconfig.build.json that extends tsconfig.json which in turn extends the top-level tsconfig.json that defines repository-wide settings.

The repository uses bun as its package manager but that does not make a difference here.

The yargs Namespace

Building the package worked with no problems after the upgrade to Yargs 18 but trying to run the executables produced a long list of errors, mostly of this form:

src/command.ts:7:14 - error TS2503: Cannot find namespace 'yargs'.

7  build(argv: yargs.Argv): yargs.Argv<object>;
               ~~~~~

The culprit was the yargs namespace. My code looked pretty much like this:

import yargs, { InferredOptionTypes } from 'yargs';

function build(argv: yargs.Argv): yargs.Argv<object> {
    return argv.options(options);
}

async function run(argv: yargs.Arguments): Promise<number> {
    // Do something.
    return 0;
}

Instead of importing the types Argv, Arguments, Options, I have used the namespace yargs throughout the code with yargs.Argv, yargs.Arguments, yargs.Options and so on. With Yargs 18, it is better to import these types and use them without the namespace:

import yargs from 'yargs';
import type { Argv, Arguments, Options, InferredOptionTypes } from 'yargs';

function build(argv: Argv): yargs.Argv<object> {
    return argv.options(options);
}

async function run(argv: Arguments): Promise<number> {
    // Do something.
    return 0;
}

If you have a larger codebase you can simply search for yargs., replace it with an empty string, and adapt the imports until the code compiles again.

Using import type instead of just import is generally wiser, when you import types (that do not appear in the resulting JavaScript). In my case it was even necessary.

Trouble with Jest

Running the tests, I faced the next unpleasant surprise:

FAIL  src/commands/validate.spec.ts
  ● Test suite failed to run

    Jest encountered an unexpected token

    Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.

    Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.

    By default "node_modules" folder is ignored by transformers.

    Here's what you can do:
     • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
     • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
     • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
     • If you need a custom transformation, specify a "transform" option in your config.
     • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

    You'll find more details and examples of these config options in the docs:
    https://jestjs.io/docs/configuration
    For information about custom transformations, see:
    https://jestjs.io/docs/code-transformation

    Details:

    /Users/me/javascript/e-invoice-eu/node_modules/yargs/index.mjs:4
    import esmPlatformShim from './lib/platform-shims/esm.mjs';
    ^^^^^^

    SyntaxError: Cannot use import statement outside a module

    > 1 | import yargs from 'yargs';
        | ^
      2 | import type { Arguments } from 'yargs';
      3 |
      4 | import { coerceOptions } from '../optspec';

      at Runtime.createScriptFromCode (../../../node_modules/jest-runtime/build/index.js:1316:40)
      at Object.<anonymous> (commands/validate.spec.ts:1:1)

Whoa! 😭 What is that?

I decided to try out the advice given in the error message one by one and started reading the Jest documentation on ESM. That sounded like a lot of work and the note "Jest ships with experimental support for ECMAScript Modules (ESM)" did not encourage me either, leave alone that I did not manage to make it work.

The Jest docs about using TypeScript do not help in this case. The next suggestions were to fiddle around with the configuration options transformIgnorePatterns and transform. Stackoverflow is full of suggestions for using that but none of them worked for me.

The last bullet point suggested to stub out the code that causes the trouble. That is a strategy that I had used successfully before with chalk, a library for producing colours and styles for terminal output.

Whether stubbing out yargs (or chalk or any other library) works, depends on the nature of your unit tests. My unit tests completely mock yargs away because I want to test my code, and not yargs. If your tests rely on the functionality of yargs a mere stub is not enough; you will have to provide the minimal functionality needed. In my case the stub was really simple.

I have added a file src/stubs/yargs.ts:

/* eslint-disable @typescript-eslint/no-unused-vars */
// Minimal runtime stub for tests — returns a chainable object.
import type { Argv } from 'yargs';

function makeStub(): any {
    const stub: any = {
        command: (..._args: any[]) => stub,
        option: (..._args: any[]) => stub,
        options: (..._args: any[]) => stub,
        help: (..._args: any[]) => stub,
        parse: (..._args: any[]) => ({}),
        demandOption: (..._args: any[]) => stub,
        middleware: (..._args: any[]) => stub,
    };
    return stub;
}

export default function yargs(_argv?: string[] | null): Argv {
    return makeStub() as Argv;
}

That provides just enough to be able to invoke the typical yargs chaining like yargs(argv).help().parse() and so on.

I also neededd to stub out yargs/helpers in [src/stubs/yargs-helpers.ts]https://github.com/gflohr/e-invoice-eu/blob/main/apps/cli/src/stubs/yargs-helpers.ts:

export const hideBin = (argv?: string[]) => {
    // fake implementation: return argv || []
    return argv ?? [];
};

Finally, Jest has to actually use these stubs. This is done with the configuration option moduleNameMapper:

{
    "moduleNameMapper": {
        "^yargs$": "<rootDir>/../src/stubs/yargs.ts",
        "^yargs/helpers$": "<rootDir>/../src/stubs/yargs.ts"
    }
}

Depending on your project structure and TypeScript configuration you may have to fiddle around a little with the paths.

Takeaways

  • Beginning with version 18, Yargs no longer ships CommonJS.
  • Avoid using the yargs namespace but rather import the Yargs types.
  • Stub out Yargs in Jest unit tests.
Leave a comment

Giving your email address is optional. But please keep in mind that you cannot get a notification about a response without a valid email address. The address will not be displayed with the comment!

Yargs 18 Pitfalls

The Easiest Method to Solve the Rubik's Cube

Abusing JSON.stringify()

Hidden Quirks of JavaScript `for...in` Loops

Creating E-Invoices with Free and Open Source Software

Dynamic Angular Configuration

This website uses cookies and similar technologies to provide certain features, enhance the user experience and deliver content that is relevant to your interests. Depending on their purpose, analysis and marketing cookies may be used in addition to technically necessary cookies. By clicking on "Agree and continue", you declare your consent to the use of the aforementioned cookies. Here you can make detailed settings or revoke your consent (in part if necessary) with effect for the future. For further information, please refer to our Privacy Policy.