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.

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!