Skip to content

buka-inc/npm.nestjs-config

Repository files navigation

@buka/nestjs-config

version downloads dependencies license Codecov

This is an easy-to-use nestjs config module with many surprising features.

Feature

  • Config verification by class-validator
  • Config transform by class-transformer
  • Load configuration files from anywhere
  • Perfect coding tips
  • Automatically handle naming styles
  • Injectable config class

Install

npm install @buka/nestjs-config
yarn install @buka/nestjs-config
pnpm install @buka/nestjs-config

Usage

@buka/nestjs-config load config from process.env and .env(local process.cwd()) by defaulted. let us create .env first:

# .env
CACHE_DIR="./tmp"
BROKERS="test01.test.com,test02.test.com,test03.test.com"

Then, define a AppConfig class with the @Configuration() decorator.

// app.config.ts
import { Configuration } from "@buka/nestjs-config";
import { IsString, IsOptional, IsIn, IsIp } from "class-validator";
import { Split } from "@miaooo/class-transformer-split";

@Configuration()
export class AppConfig {
  // set default value
  @IsIp()
  host = "0.0.0.0";

  // CACHE_DIR in .env
  @IsString()
  @IsOptional()
  cacheDir?: string;

  // process.env.NODE_ENV
  @IsIn(["dev", "test", "prod"])
  nodeEnv: string;

  @Split(",")
  brokers: string[];
}

Tip

@buka/nestjs-config automatically convert naming styles. For example: cache_dirCACHE_DIRcacheDirCacheDircache-dirCache_Dir are considered to be the same config name.

Import ConfigModule in your AppModule:

// app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@buka/nestjs-config";
import { AppConfig } from "./app.config";

@Module({
  // use process.env and read .env by defaulted
  imports: [ConfigModule.register({ isGlobal: true })],
})
export class AppModule {}

Inject and use AppConfig in your service:

import { Injectable } from "@nestjs/common";
import { AppConfig } from "./app.config";

@Injectable()
export class AppService {
  constructor(private readonly appConfig: AppConfig) {}
}

Nested Configuration

Nested configuration is the same as using class-validator and class-transformer:

import { Configuration } from "@buka/nestjs-config";
import { IsString } from "class-validator";

export class SubConfig {
  // process.env.{ParentFieldName}__KEY
  @IsString()
  key: string;
}

@Configuration()
export class AppConfig {
  // process.env.SUB_FIRST__KEY
  @ValidateNested()
  @Type(() => SmsTemplate)
  subFirst!: SubConfig;

  // process.env.SUB_SECOND__KEY
  @ValidateNested()
  @Type(() => SmsTemplate)
  subSecond!: SubConfig;
}

Add more dotenv files

import { Module } from "@nestjs/common";
import {
  ConfigModule,
  processEnvLoader,
  dotenvLoader,
} from "@buka/nestjs-config";
import { AppConfig } from "./app.config";

@Module({
  imports: [
    ConfigModule.register({
      isGlobal: true,
      loaders: [
        processEnvLoader,
        // transform DATABASE__HOST="0.0.0.0"
        // to DATABASE = { HOST: "0.0.0.0" }
        // transform LOG="true"
        // to LOG = true
        dotenvLoader(".env", { separator: "__", jsonParse: true }),
        dotenvLoader(`.${process.env.NODE_ENV}.env`),
      ],
    }),
  ],
})
export class AppModule {}

Custom config loader

// yaml-config-loader.ts
import { ConfigLoader } from "@buka/nestjs-config";
import { parse } from "yaml";

export async function yamlConfigLoader(filepath: string): ConfigLoader {
  return (options: ConfigModuleOptions) => {
    if (!existsSync(filepath)) {
      if (!options.suppressWarnings) {
        Logger.warn(`yaml file not found: ${filepath}`);
      }

      return {};
    }

    const content = await readFile(filepath);
    return parse(content);
  };
}

Use yamlConfigLoader:

import { Module } from "@nestjs/common";
import { ConfigModule } from "@buka/nestjs-config";
import { AppConfig } from "./app.config";
import { yamlConfigLoader } from "./yamlConfigLoader";

@Module({
  imports: [
    ConfigModule.register({
      isGlobal: true,
      loaders: [yamlConfigLoader("my-yaml-config.yaml")],
    }),
  ],
})
export class AppModule {}

Add prefix to all class properties

// mysql.config.ts
import { Configuration } from "@buka/nestjs-config";
import { IsString } from "class-validator";

@Configuration("mysql.master")
export class MysqlConfig {
  // process : process.env.MYSQL__MASTER__HOST
  // .env    : MYSQL__MASTER__HOST
  // .json   : { mysql: { master: { host: "" } } }
  @IsString()
  host: string;
}

Custom the config name of property

// app.config.ts
import { Configuration, ConfigKey } from "@buka/nestjs-config";
import { IsString } from "class-validator";

@Configuration("mysql.master")
export class MysqlConfig {
  // process : process.env.DATABASE_HOST
  // .env    : DATABASE_HOST
  // .json   : { databaseHost: "" }
  @ConfigKey("DATABASE_HOST")
  @IsString()
  host: string;
}

@ConfigKey(name) will overwrite the prefix of @Configuration([prefix])

Remove warning logs

import { Module } from "@nestjs/common";
import { ConfigModule } from "@buka/nestjs-config";
import { AppConfig } from "./app.config";

@Module({
  imports: [
    ConfigModule.register({
      isGlobal: true,
      suppressWarnings: true,
    }),
  ],
})
export class AppModule {}

ConfigModule.inject(ConfigProvider, DynamicModule[, dynamicModuleOptions])

Simplify the writing of .forRootAsync/.registerAsync.

// pino.config.ts
@Configuration("pino")
export class PinoConfig implements Pick<Params, "assignResponse"> {
  @ToBoolean()
  @IsBoolean()
  assignResponse?: boolean | undefined;
}

// app.module.ts
@Module({
  imports: [
    ConfigModule.register({ isGlobal: true }),
    ConfigModule.inject(PinoConfig, LoggerModule),
  ],
})
class AppModule {}

If the config class implement options of module .forRootAsync/.registerAsync, The code will become very beautiful.

And implement is not necessary:

import { Module } from "@nestjs/common";
import { ConfigModule } from "@buka/nestjs-config";

// pino.config.ts
@Configuration("pino")
export class PinoConfig {
  @IsIn(["fatal", "error", "warn", "info", "debug", "trace"])
  level: string = "info";
}

// app.module.ts
@Module({
  imports: [
    ConfigModule.register({ isGlobal: true }),
    // map .level to .pinoHttp.level
    ConfigModule.inject(PinoConfig, LoggerModule, (config) => ({
      pinoHttp: { level: config.level },
    })),
  ],
})
class AppModule {}

Sometimes, a name property is need by options of .forRootAsync/.registerAsync, like add multiple database in @nestjs/typeorm.

Another one is isGlobal

@Module({
  imports: [
    ConfigModule.register({ isGlobal: true }),

    ConfigModule.inject(
      TypeOrmConfig,
      TypeOrmModule,
      { name: "my-orm" },
      (config) => config // config mapping function is optional
    ),

    // this is equal to
    TypeOrmModule.forRootAsync({
      name: "my-orm",
      inject: [TypeOrmConfig],
      useFactory: (config: TypeOrmConfig) => config,
    }),
  ],
})
export class AppModule {}

Preload Config

Sometimes, we have to get config outside the nestjs lifecycle. ConfigModule.preload(options) is designed for this.

There is an example of MikroORM config file:

// mikro-orm.config.ts
import { ConfigModule } from "@buka/nestjs-config";
import { MySqlDriver, defineConfig } from "@mikro-orm/mysql";
import { MysqlConfig } from "./config/mysql.config";
import { Migrator } from "@mikro-orm/migrations";
import { BadRequestException } from "@nestjs/common";

export default (async function loadConfig() {
  // Load MysqlConfig
  await ConfigModule.preload();

  // Get MysqlConfig Instance
  const config = await ConfigModule.get(MysqlConfig);

  return defineConfig({
    ...config,
    entities: ["dist/**/*.entity.js"],
    driver: MySqlDriver,
  });
})();

Tip

The options of ConfigModule.preload(options) is the options of ConfigModule.register(options)

Q&A

Reported every field in my Config class was missing, even though they weren't.

This may be due to target in tsconfig.json is ES2021 or lower. We recommend using ES2022 and above. But, if you must use ES2021, every property key should add @ConfigKey() decorator (See More):

// app.config.ts
import { Configuration, ConfigKey } from "@buka/nestjs-config";
import { IsIp, IsIn } from "class-validator";

@Configuration()
export class AppConfig {
  @ConfigKey()
  @IsIp()
  host = "0.0.0.0";

  @ConfigKey()
  @IsIn(["dev", "test", "prod"])
  nodeEnv: string;
}

Nest could not find YourConfig element.

@buka/nestjs-config will autoload all the config classes injected by service. However, a config that is not used by any service may not be injected into the nestjs app. And this will causes you to get this error when attempt to app.get(YourConfig).

One solution is use ConfigModule.get(YourConfig) replace app.get(YourConfig):

await ConfigModule.preload();
const yourConfig = await ConfigModule.get(YourConfig);

// If you do this, you probably want to do something outside of the nestjs runtime.
// do...

If you have to inject config class which is not used by any service, you can do it like this:

@Module({
  ConfigModule.register({
    isGlobal: true,
    providers: [YourConfig]
  }),
})
class AppModule {}