Using Pure ESM Modules in a NestJS Project: A Step-by-Step Guide

James Harrison - Founder of Vast Technology Inc.
James Harrison
5 min read
·
November 26, 2024

As JavaScript continues to evolve, more libraries are adopting ECMAScript modules (ESM) as the standard for module definition. However, integrating ESM-only packages into projects like NestJS—which traditionally relies on CommonJS—can be challenging. This guide will show you how to load pure ESM modules dynamically in a NestJS application using an example with the libraries remark-parse, unified, and remark-frontmatter.

By the end of this tutorial, you will understand how to:

  • Dynamically load ESM modules with import().
  • Define TypeScript types for the ESM modules.
  • Provide these modules as asynchronous custom providers in a NestJS application.
  • Inject and use them in your services.

Why Pure ESM Modules Can Be Tricky in NestJS

NestJS projects are often initialized with TypeScript configurations that favor CommonJS for compatibility. ESM-only packages cannot be directly require()'d in such setups, leading to errors. While migrating the entire project to ESM is an option, it can introduce significant overhead, especially in larger applications. The approach we’ll take—loading ESM modules dynamically—lets us work around this limitation while retaining most of the project's existing setup.

Our Solution

We will demonstrate how to integrate ESM modules by dynamically loading them at runtime and providing them to the application as dependencies. Our example will use the following libraries:

These tools will be used to process a Markdown string containing frontmatter and convert it into an abstract syntax tree (AST).

Step 1: Configure Your Environment

This article assumes you have an existing NestJS project to work with. Let's get started by installing the example ESM packages from NPM:

$ npm i unified remark-parse remark-frontmatter

To use ESM in a Node.js project, update your tsconfig.json to support ESM. Set the module property to Node16 (or NodeNext if you're targeting experimental features):

{
  "compilerOptions": {
    "module": "Node16", // <- Change to Node16 instead of CommonJS
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "ES2021",
    "sourceMap": true,
    "outDir": "./dist",
    "baseUrl": "./",
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": false,
    "noImplicitAny": false,
    "strictBindCallApply": false,
    "forceConsistentCasingInFileNames": false,
    "noFallthroughCasesInSwitch": false
  }
}

This ensures that TypeScript understands how to handle both CommonJS and ESM modules. Specifically, it prevents the following error being thrown when starting our NestJS app:

ERROR [ExceptionHandler] require() of ES Module /path/to/file.js from /path/to/otherFile.js not supported.

Another extremely important step is to ensure your Typescript version is v5.3 or higher. This is because Typescript v5.3 introduced resolution-mode support in all module modes which will prevent our code from displaying an error when we dynamically import the ESM packages.

Step 2: Create a Dynamic Module Loader

Next, create a utility file (loadEsm.ts) to dynamically load the ESM dependencies:

export type RemarkParse = Awaited<
  ReturnType<typeof loadDependencies>
>['remarkParse'];
export type Unified = Awaited<ReturnType<typeof loadDependencies>>['unified'];
export type RemarkFrontmatter = Awaited<
  ReturnType<typeof loadDependencies>
>['remarkFrontmatter'];

export async function loadDependencies() {
  const remarkParse = await import('remark-parse');
  const unified = await import('unified');
  const remarkFrontmatter = await import('remark-frontmatter');

  const res = {
    remarkParse: remarkParse.default,
    unified: unified.unified,
    remarkFrontmatter: remarkFrontmatter.default,
  };

  return res;
}

This file:

  1. Dynamically imports the ESM modules using import().
  2. Exports their types for TypeScript compatibility.
  3. Returns an object containing the loaded modules.

Step 3: Define Constants for Dependency Injection

Add constants to represent the providers for your dependencies. For example, create a constants.ts file:

export const REMARK_PARSE = 'REMARK_PARSE';
export const UNIFIED = 'UNIFIED';
export const REMARK_FRONTMATTER = 'REMARK_FRONTMATTER';

Step 4: Configure Your Module with Async Custom Providers

In app.module.ts, use asynchronous factories to load the ESM dependencies and provide them as injectable tokens:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { loadDependencies } from './loadEsm';
import { REMARK_FRONTMATTER, REMARK_PARSE, UNIFIED } from './constants';

@Module({
  controllers: [AppController],
  providers: [
    {
      provide: REMARK_PARSE,
      useFactory: async () => {
        const dependencies = await loadDependencies();
        return dependencies.remarkParse;
      },
    },
    {
      provide: UNIFIED,
      useFactory: async () => {
        const dependencies = await loadDependencies();
        return dependencies.unified;
      },
    },
    {
      provide: REMARK_FRONTMATTER,
      useFactory: async () => {
        const dependencies = await loadDependencies();
        return dependencies.remarkFrontmatter;
      },
    },
    AppService,
  ],
})
export class AppModule {}

This approach uses useFactory to handle asynchronous loading and provides each module as a dependency.

Step 5: Inject and Use the Dependencies in a Service

Finally, inject the ESM dependencies into a service to use them:

import { Inject, Injectable } from '@nestjs/common';
import { REMARK_FRONTMATTER, REMARK_PARSE, UNIFIED } from './constants';
import { RemarkFrontmatter, RemarkParse, Unified } from './loadEsm';

@Injectable()
export class AppService {
  constructor(
    @Inject(REMARK_PARSE) private readonly remarkParse: RemarkParse,
    @Inject(UNIFIED) private readonly unified: Unified,
    @Inject(REMARK_FRONTMATTER)
    private readonly remarkFrontmatter: RemarkFrontmatter,
  ) {}

  getHello(): string {
    const processor = this.unified()
      .use(this.remarkParse)
      .use(this.remarkFrontmatter, ['yaml']);

    const tree = processor.parse(`
      ---
      title: Hello World
      ---
      # Hello World
      This is my Markdown file
    `);

    if (!tree) {
      throw new Error('Tree is missing');
    }
    return JSON.stringify(tree);
  }
}

In this code:

  • The injected dependencies (remarkParse, unified, and remarkFrontmatter) are used to process a Markdown string with frontmatter.
  • The result is an abstract syntax tree (AST) representing the Markdown content.

Conclusion

By dynamically loading ESM modules and using asynchronous custom providers, you can seamlessly integrate ESM-only libraries into your NestJS project without needing a full migration to ESM. This approach is flexible, type-safe, and ensures compatibility with your existing codebase.

With this setup, you can now leverage the growing ecosystem of ESM packages in your NestJS applications. Happy coding!

Heading

James Harrison - Founder of Vast Technology Inc.
James Harrison
This is some text inside of a div block.
·
This is some text inside of a div block.