Create dynamic module made easy in NestJS 9

Reading Time: 4 minutes

 59 total views

Introduction

Our technology team builds a lot of Nestjs libraries to reuse in Nestjs applications. Our libraries provide forRoot() to create dynamic module only. Therefore, we cannot call forRootAsync() to register asynchronous module by importing modules and injecting services.

Before V9, there are many boilerplate codes to get forRootAsync() to work. Internally, forRootAsync invokes createAsyncProvider and createAsyncOptionsProviders private static methods and both of them accept some sort of ModuleAsyncOptions interface. As the result, the team avoids the implementation unless it is absolutely required.

NestJS 9 introduces ConfiguratableModuleBuilder and it simplifies the steps to create dynamic module by eliminating most of the boilerplate codes.

In this blog post, I describe how to create a nestjs library that provides forRoot and forRootAsync with ConfigurableModuleBuilder. Then, I can install it in a new nestjs 9 app to register the module either synchronously or asynchronously.

let's go

Upgrade NestJS to V9

npm install -g @nestjs/cli npm-check-updates
nest update
npm-check-updates "/@nestjs*/" -u

Create a new nestjs library

nest new nestjs-world-cup-lib

The example is a dummy library to return World Cup finals from 1930 to 2018.

Rename src/ to lib/ and update tsconfig.json accordingly

nestjs-world-cup-lib
├── README.md
├── cspell.json
├── lib
│   ├── enums
│   │   ├── countries.enum.ts
│   │   └── index.ts
│   ├── index.ts
│   ├── interfaces
│   │   ├── index.ts
│   │   ├── world-cup-module-options.interface.ts
│   │   └── world-cup-result.interface.ts
│   ├── services
│   │   ├── index.ts
│   │   └── world-cup.service.ts
│   ├── world-cup-module-definition.ts
│   └── world-cup.module.ts
├── nest-cli.json
├── package-lock.json
├── package.json
├── tsconfig.build.json
└── tsconfig.json
// tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "es2017",
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./lib",
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": false,
    "noImplicitAny": false,
    "strictBindCallApply": false,
    "forceConsistentCasingInFileNames": false,
    "noFallthroughCasesInSwitch": false,
    "strict": true,
    "strictPropertyInitialization": false,
    "noImplicitThis": true,
    "noUnusedParameters": true,
    "noUnusedLocals": true,
    "noUncheckedIndexedAccess": true
  },
  "include": ["lib/**/*"],
  "exclude": ["dist", "test", "node_modules"]
}

Update outDir to ./dist and rootDir to ./lib. Include ['lib/**/*'] and excludes ["dist", "test", "node_modules"] from compilation

Steps to create dynamic module

  • Create an interface for the options
  • Instantiate ConfigurableModuleBuilder and export ConfigurableModuleClass and MODULE_OPTIONS_TOKEN
  • Generate providers, services, etc that register and export from the new module
  • Add the configurable module that extends ConfigurableModuleClass
import { ConfigurableModuleBuilder } from '@nestjs/common'
import { WorldCupModuleOptions } from './interfaces'

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<WorldCupModuleOptions>()
  .setClassMethodName('forRoot')
  .build()

Step 1: Create an interface for the options

The simple interface has 2 options: the year World Cup is held and your favorite world cup team

// world-cup-module-options.interface.ts

export interface WorldCupModuleOptions {
  year: number
  favoriteCountry: string
}

Step 2: Instantiate ConfigurableModuleBuilder

This step is identical for every dynamic module. We instantiate ConfigurableModuleBuilder and export ConfigurableModuleClass and MODULE_OPTIONS_TOKEN

// world-cup-module-definition.ts

import { ConfigurableModuleBuilder } from '@nestjs/common'
import { WorldCupModuleOptions } from './interfaces'

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<WorldCupModuleOptions>()
  .setClassMethodName('forRoot')
  .build()

ConfigurableModuleBuilder generates register and registerAsync by default. In my example, I want to use forRoot and forRootAsync instead. Therefore, I override the configuration by setClassMethodName('forRoot').

Step 3: Generate a service and export from module

// world-cup.service.ts

@Injectable()
export class WorldCupService {
  constructor(@Inject(MODULE_OPTIONS_TOKEN) private options: WorldCupModuleOptions) {}

  getYear(): number {
    return this.options.year
  }
}

The service injects MODULE_OPTIONS_TOKEN in the constructor to access options. We can access the values by this.options.year and this.options.favoriteCountry respectively.

Add the configurable module that extends ConfigurableModuleClass

import { Module } from '@nestjs/common'
import { WorldCupService } from './services'
import { ConfigurableModuleClass } from './world-cup-module-definition'

@Module({
  providers: [WorldCupService],
  exports: [WorldCupService],
})
export class WorldCupModule extends ConfigurableModuleClass {}

The new module extends ConfigurableModuleClass but we can register and export services the same way as before.

The implementation of the library is done, we can publish it to npmjs and try it in a new nestjs V9 application

Register dynamic module and test in nestjs V9 application

npm i nestjs-world-cup-lib
import { WorldCupModule } from 'nestjs-world-cup-lib'

@Module({
  imports: [
    /* Register synchronous dynamic module */
    WorldCupModule.forRoot({
       year: 2018service.get<number>('YEAR'),
       favoriteCountry: 'Brazil'
    })    

    /* Register asynchronous dynamic module
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: Joi.object({
        YEAR: Joi.number().optional(),
        FAVORITE_COUNTRY: Joi.string().optional(),
      }),
    }),
    WorldCupModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (service: ConfigService) => ({
        year: service.get<number>('YEAR'),
        favoriteCountry: service.get<string>('FAVORITE_COUNTRY'),
      })
    })
     */
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Test WorldCupService in AppController

import { WorldCupService } from 'nestjs-world-cup-lib'

@Controller()
export class AppController {
  constructor(private worldCupService: WorldCupService) {}

  @Get('world-cup-year')
  getWorldCupYear(): number {
    return this.worldCupService.getYear()
  }
}

Type http://localhost:3000/world-cup-year in the browser to verify the endpoint.

Final Thoughts

In this post, we see one of the new changes of nestjs v9 that is ConfigurableModuleBuilder. ConfigurableModuleBuilder class simplify the process to create dynamic module. When dynamic module can register without dependency, register, forRoot and forFeature methods are suffice. Alternatively, dynamic module that depends on dependency to initialize configurable options should use registerAsync, forRootAsync and forFeatureAsync counterparts.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in NestJS and other technologies.

Resources:

  1. https://docs.nestjs.com/fundamentals/dynamic-modules#configurable-module-builder
  2. nestjs-world-cup-lib repo: https://github.com/railsstudent/nestjs-world-cup-lib
  3. Source code of ConfigurableModuleBuilder – https://github.com/nestjs/nest/blob/master/packages/common/module-utils/configurable-module.builder.ts
  4. How to update nestjs: https://blog.bitsrc.io/how-to-update-nest-js-ecd0e9466165

Author nestjs library using Angular Schematics

Reading Time: 7 minutes

 58 total views

Introduction

When I create nestjs project to do proof of concept, I usually spend the first hour to install dependencies and add rules to eslintrc.js before writing any meaningful code. When I researched code generation in nestjs, I found out that angular schematics can author nestjs library to upgrade configurations and add new files. Therefore, I decide to design a library that updates prettier configuration and generates eslint prettier rule using schematics.

In this blog post, I author a nestjs library, test the library on nestjs application locally and publish it to npmjs.com.

let's go

Install essential dependencies to author nestjs library

npm install -g @angular-devkit/schematics-clinpm install --save-dev --save-exact cpx@1.5.0 lodash@4.17.21 rimraf@3.0.2 typescript@4.7.2
npm install --save-exact @angular-devkit/core@13.3.0 @angular-devkit/schematics@13.3.0 @schematics/angular@13.3.0

Generate new schematics project to author nestjs library

schematics blank --name=nest-prettier

My habit is to rename src to schematics

nest-prettier
├── package.json
├── package-lock.json
├── README.md
├── schematics
│   ├── collection.json
│   └── nest-prettier
│       ├── index_spec.ts
│       └── index.ts
└── tsconfig.json

Update collection.json

As the name implies, collection.json is a collection of schematics in the library. In this library, I have one schematic that is nest-add that I can install via

nest add nest-prettier

to apply changes within the the latest version of nestjs application

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "nest-add": {
      "description": "Run prettier as an eslint rule.",
      "factory": "./nest-prettier/index#nestPrettier",
      "schema": "./nest-prettier/schema.json"
    }
  }
}

Factory method is nestPrettier in index.ts and schema.json contains a list of questions that prompts users to answer. When users answer them, properties are updated in the schematic context

// schema.ts 

export interface Schema {
  printWidth: number
  tabWidth: number
  useTabs: boolean
  semi: boolean
  singleQuote: boolean
  trailComma: string
  bracketSpacing: boolean
  eslintFileFormat: string
  quoteProps: string
  arrowParens: string
  requirePragma: boolean
  insertPragma: boolean
}

Define the build process of the schematics

When authoring nestjs library, we intend to build and publish the library to npmjs.com for other developers to use. First, we configure tsconfig.json and add new npm scripts in package.json.

// tsconfig.json
{
  "compilerOptions": {
    ...
    "rootDir": "schematics",
    "outDir": "dist",
    ...
  },
  "include": ["schematics/**/*"],
  "exclude": ["schematics/*/files/**/*"]
}

Second, We change “rootDir” from “src” to “schematics” and add "outDir": "dist" to build the schematic in dist/ directory. We modify include array to include files in schematics/ and excludes templates in files/ from compilation.

Then, we tell schematics where to find the collection.json in package.json.

"schematics": "./dist/collection.json",

nest-prettier is a dev dependency in nestjs application

"nest-add": {
  "save": "devDependencies"
},

Next, add prebuild, copy:collection, copy:schematics, postbuild and manual-test scripts in package.json

"scripts": {
    "prebuild": "rimraf dist",
    "build": "tsc -p tsconfig.json",
    "copy:collection": "cpx schematics/collection.json dist && cpx 'schematics/**/schema.json' dist",
    "copy:schematics": "cpx 'schematics/**/files/**' dist",
    "postbuild": "npm run copy:collection && npm run copy:schematics",
    "manual-test": "npm run build && schematics .:nest-add"
}

It is important to copy collection.json, schematic.json and templates in post build stage.

Finally, manual-test script permits us to test the schematic in debug mode without generating new output file.

Create factory method of the schematic

// nest-prettier/index.ts

export function nestPrettier(options: Schema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.addTask(new NodePackageInstallTask())

    addDependencies(tree, context, [prettier, eslintConfigPrettier, eslintPluginPrettier])
    return chain([addPrettierConfig(options), addEsnlintPrettier(options)])
  }
}

Let’s outline the main flow of the schematic

  • Add NodePackageInstallTask to install the library that is mandatory
  • Update package.json of nestjs application to install prettier, eslint-config-prettier and eslint-plugin-prettier dev dendencies
  • Create a single rule that updates .prettierrc and generates the eslint prettier rule from eslintrc-prettier.template template

Implement new schematic rules

In this simple nestjs library, there are 2 rules:

addPrettierConfig – this rule overwrites .prettierrc with user inputs

addEslintPrettier – this rule generates the eslint prettier rule in eslintrc-prettier.template that we have to manually copy to .eslintrc.js. It is because schematics does not have API that can update javaScript file easily.

Overwrite Prettier configuration file

export function addPrettierConfig(options: Schema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    const prettierOptions: Record<string, string | number | boolean> = {
      singleQuote: options.singleQuote,
      trailingComma: options.trailComma,
      tabWidth: options.tabWidth,
      semi: options.semi,
      printWidth: options.printWidth,
      bracketSpacing: options.bracketSpacing,
      useTabs: options.useTabs,
      arrowParens: options.arrowParens,
      quoteProps: options.quoteProps,
      requirePragma: options.requirePragma,
      insertPragma: options.insertPragma,
    }

    const configFileName = '.prettierrc'
    if (tree.exists(configFileName)) {
      tree.overwrite(configFileName, JSON.stringify(prettierOptions, null, 2))
      context.logger.info(`${configFileName} is overwritten`)
    } else {
      tree.create(configFileName, JSON.stringify(prettierOptions, null, 2))
      context.logger.info(`Created ${configFileName}`)
    }
    return tree
  }
}

The code loads the options from Schema interface and invokes Tree API to overwrite .prettierrc with the new format options.

Copy eslint prettier rule to eslint configuration file configuration file

First, we create a template, eslint-prettier.template in nest-prettier/files directory.

extends: ['plugin:prettier/recommended']
rules: {
    'prettier/prettier': ['error', {
        'singleQuote': <%= singleQuote %>, 
        'trailingComma': '<%= trailComma %>', 
        'tabWidth': <%= tabWidth %>, 
        'semi': <%= semi %>, 
        'printWidth': <%= printWidth %>, 
        'bracketSpacing': <%= bracketSpacing %>, 
        'useTabs': <%= useTabs %>,
        'arrowParens': '<%= arrowParens %>',
        'quoteProps': '<%= quoteProps %>',
        'requirePragma': <%= requirePragma %>,
        'insertPragma': <%= insertPragma %>, 
    }]
}

<% singleQuote %> is a parameterized variable that will be replaced by singleQuote property of Schema interface. It is also true to the rest of the parameterized variables.

// eslint-prettier.helper.ts

import { apply, Rule, SchematicContext, template, Tree, mergeWith, url } from '@angular-devkit/schematics'
import { strings } from '@angular-devkit/core'

export function addEslintPrettier(options: Schema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    const configFileName = '.eslintrc.js'
    const buffer = tree.read(configFileName)
    if (!buffer) {
      return tree
    }

    ...
    const eslintTemplate = './eslintrc-prettier.template'
    if (tree.exists(eslintTemplate)) {
       tree.delete(eslintTemplate)
    }

    const sourceTemplate = url('./files')
    const sourceParameterizedTemplate = apply(sourceTemplate, [
        template({
          ...options,
          ...strings,
        }),
    ])
    
    return mergeWith(sourceParameterizedTemplate)(tree, context)
  }
}

url('/files') reads all the files in nest-prettier/files directory; apply replaces variables with option values and mergeWith adds the templates to the source tree of the nestjs application.

As the result, the rules have set up to achieve file format in prettier and eslint configuration files. Before publishing the library, we should test the schematic in debug mode and/or in a local nestjs application

Test in debug mode after authoring a nestjs library

npm run manual-test  

compiles the codes to dist/ directory and executes schematics .:nest-add.

We choose our prettier configuration in debug mode. Suppose the schematic is correct, the outcome is to overwrite .prettierrc and create eslintrc-prettier.template.

Debug mode enabled by default for local collections.
? Eslint file format. javascript
? What is the print width per line? 120
? Specify the number of spaces per indentation-level. 2
? Indent lines with tabs instead of spaces. No
? Print semicolons at the ends of statements. Yes
? Use single quotes instead of double quotes. No
? Change when properties in objects are quoted. as-needed
? Print trailing commas wherever possible in multi-line comma-separated syntactic structures. all
? Print spaces between brackets in object literals. Yes
? Include parentheses around a sole arrow function parameter. always
? Prettier can restrict itself to only format files that contain a special comment, called a pragma, at the top of the 
file. This is very useful when gradually transitioning large, unformatted codebases to prettier. No
? Prettier can insert a special @format marker at the top of files specifying that the file has been formatted with 
prettier. This works well when used in tandem with the --require-pragma option. No
    Found prettier@2.7.1, do not add dependency
    Found eslint-config-prettier@8.5.0, do not add dependency
    Found eslint-plugin-prettier@4.2.1, do not add dependency
    .prettierrc is overwritten
    Does not support .eslintrc.js
    Append plugin and rule from ./eslintrc-prettier.template to .eslintrc.js. Then, delete the template file.
CREATE eslintrc-prettier.template (425 bytes)
UPDATE .prettierrc (259 bytes)
Dry run enabled by default in debug mode. No files written to disk.

Test in nestjs application after authoring a nestjs library

Debug mode does not create anything; therefore, I suggest to test the library in a local nestjs application.

First, we run npm link command to link the nest-prettier dependency to nest-prettier library

npm link ../nest-prettier

Then, execute nest-add schematic of the nest-prettier library

schematics nest-prettier:nest-add
? Eslint file format. javascript
? What is the print width per line? 140
? Specify the number of spaces per indentation-level. 2
? Indent lines with tabs instead of spaces. No
? Print semicolons at the ends of statements. Yes
? Use single quotes instead of double quotes. No
? Change when properties in objects are quoted. as-needed
? Print trailing commas wherever possible in multi-line comma-separated syntactic structures. all
? Print spaces between brackets in object literals. Yes
? Include parentheses around a sole arrow function parameter. always
? Prettier can restrict itself to only format files that contain a special comment, called a pragma, at the top of the file.
 This is very useful when gradually transitioning large, unformatted codebases to prettier. No
? Prettier can insert a special @format marker at the top of files specifying that the file has been formatted with 
prettier. This works well when used in tandem with the --require-pragma option. No
    Found prettier@2.7.1, do not add dependency
    Found eslint-config-prettier@8.5.0, do not add dependency
    Found eslint-plugin-prettier@4.2.1, do not add dependency
    .prettierrc is overwritten
    Does not support .eslintrc.js
    Append plugin and rule from ./eslintrc-prettier.template to .eslintrc.js. Then, delete the template file.
CREATE eslintrc-prettier.template (425 bytes)
UPDATE .prettierrc (259 bytes)
✔ Packages installed successfully.

We verify that schematic has replaced the variables of .eslintrc-prettier.template with option values

extends: ['plugin:prettier/recommended']
rules: {
    'prettier/prettier': ['error', {
        'singleQuote': false, 
        'trailingComma': 'all', 
        'tabWidth': 2, 
        'semi': true, 
        'printWidth': 140, 
        'bracketSpacing': true, 
        'useTabs': false,
        'arrowParens': 'always',
        'quoteProps': 'as-needed',
        'requirePragma': false,
        'insertPragma': false, 
    }]
}

The manual work is to copy “prettier/prettier” and prettier plugin to .eslintrc.js

// .eslintrc.js

extends: [
  'plugin:@typescript-eslint/recommended',
  'plugin:prettier/recommended'
],

rules: {
    '@typescript-eslint/interface-name-prefix': 'off',
    '@typescript-eslint/explicit-function-return-type': 'off',
    '@typescript-eslint/explicit-module-boundary-types': 'off',
    '@typescript-eslint/no-explicit-any': 'off',
    'prettier/prettier': ['error', {
      'singleQuote': false, 
      'trailingComma': 'all', 
      'tabWidth': 2, 
      'semi': true, 
      'printWidth': 140, 
      'bracketSpacing': true, 
      'useTabs': false,
      'arrowParens': 'always',
      'quoteProps': 'as-needed',
      'requirePragma': false,
      'insertPragma': false, 
    }]
},

Publish the nestjs library

My experience is to publish the library on the command line. We need to create an account in npmjs.com and enable 2FA to secure the account.

Subsequently, go back to the command line to add user on the machine

npm adduser

npm notice Log in on https://registry.npmjs.org/
Username:
Password: 
Email: (this IS public)

Then, publish the library and input OTP to deploy to npmjs.com

npm publish
Enter OTP:

Final thoughts

In this post, we have seen that writing nestjs library is made possible by Angular schematics. After testing the library locally, we can publish it to npmjs.com, execute nest add command to install the library and generates code in nestjs application

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

  1. nest-prettier library: https://www.npmjs.com/package/nest-prettier
  2. Generating code using schematics: https://angular.io/guide/schematics