Skip to content

API reference / @backpack/config

@backpack/config ⚙️️

Type-safe configuration management using Zod.

Installing

First, ensure you have set up Nexus as private registry needed to use Backpack.

Then, install the package using npm (along with Zod):

bash
npm install @backpack/config zod

Usage

Step 1: define your config schema

First, create a schema using Zod. You can use any of Zods features (such as transforms and default values), as long as the top-level data structure is an object.

Example:

ts
import { z } from "zod";

export const Config = z.object({
  appName: z.string(),
  clients: z.object({
    foo: z.object({
      baseUrl: z.string().url(),
      timeoutMilliseconds: z.number().default(200),
    }),
    bar: z.object({
      baseUrl: z.string().url(),
      timeoutMilliseconds: z.number().default(200),
    }),
  })
});

Step 2: load your config

To load your actual config, you can use one or more loaders.

If multiple loaders are provided, it will deep-merge the data from all loaders, with the last loader taking precedence. If any loader fails, it will still return the data from other loaders.

ts
import { loadConfig, envLoader, yamlLoader, importLoader } from "@backpack/config";

const myConfig = await loadConfig({
  schema: Config,
  loaders: [
    // loads from environment variables
    envLoader(),

    // loads from a YAML file
    yamlLoader("path/to/config.yaml"),

    // you need to implement your own environment/profile detection 
    //  to load from a specific source:
    yamlLoader(`path/to/config-${profile}.yaml`),

    // loads from a TypeScript file, using a dynamic import:
    importLoader(() => import("./path/to/config.ts")),
  ],
});

Step 3: specify your actual config files

Make sure the sources of your loaders are provided according to your schema:

Example using YAML:

yaml
appName: My app
clients:
  foo:
    baseUrl: https://path.to/foo
    timeoutMilliseconds: 500
# ...

Example using TypeScript:

ts
// (make sure to use a default export)
export default {
  appName: "My app",
  clients: {
    foo: {
      baseUrl: "https://path.to/foo",
      timeoutMilliseconds: 500,
    }
    // ...
  }
}

Advanced

Type-safety

You can leverage your Zod-schema for additional type-safety.

First, use the z.infer<> helper to create a type-alias:

ts
import { z } from "zod";

export type Config = z.infer<typeof Config>;
export const Config = z.object({
  // ...
});

Next, use the satisfies operator to get additional type-checking.

ts
import type { Config } from "path/to/config.ts";

export default {
  appName: "My app",
  clients: {
    foo: {
      baseUrl: "https://path.to/foo",
      timeoutMilliseconds: 500,
    }
    // ...
  }
} satisfies Config;

If you are using multiple loaders to combine several partial configs, you can use DeepPartial<Config> to make all properties optional. Alternatively, you can use the defineConfig() helper - which accepts partial configs by default:

ts
import { defineConfig } from "@backpack/config";
import type { Config } from "path/to/config.ts";

export default defineConfig<Config>({
  // app name is defined in a different config file.

  clients: {
    foo: {
      baseUrl: "https://path.to/foo",
      timeoutMilliseconds: 500,
    }
    // ...
  }
});

Passing context

In some cases, you may want to pass additional context to your config file. For example, to access certain things from your application context, such as an injection container.

To do this, you can use the importLoader() like this:

ts
import { loadConfig, envLoader, yamlLoader, importLoader } from "@backpack/config";

export type Context = { /* ... */ }

const myConfig = await loadConfig({
  schema: Config,
  loaders: [
    importLoader({
      read: () => import("./path/to/config.ts"),
      context: { /* ... */ }
    }),
  ],
});

Now, you can specify your TypeScript config as an arrow function:

ts
import { defineConfig } from "@backpack/config";
import type { Config } from "path/to/config.ts";

export default defineConfig<Config, Context>((context) => ({
  // ...
}));

Migrating from @ns/cdk-helper

If you are using @ns/cdk-helper, you may want to migrate your project to use @backpack/config instead.

This will bring more type-safety to your CDK config, especially when you are using additional properties. It also provides improved runtime safety using schema validation, making sure you have the right values in your YAML files.

1. Create a Zod schema for your CDK config

Add a Zod schema representing your current configuration model:

ts
import { z } from "zod";

export type BuildConfig = z.infer<typeof BuildConfig>;
export const BuildConfig = z.object({
  App: z.string(),
  AwsAccount: z.string(),
  AwsRegion: z.string(),
  Environment: z.enum(["dev", "stg", "prd"]),
  Tags: z.record(z.string(), z.string()),
});

TIP

This example uses the same naming convention as @ns/cdk-helper. Feel free to use a different naming convention (e.g. "camelCase" over "PascalCase") for your property keys.

The @backpack/config library provides lenient matching, accepting different casing in your config files. If you do so, remember to update all references in your code.

2. Replace your config loading logic

Change your existing code:

ts
import { App } from "aws-cdk-lib";
import { Config } from "@ns/cdk-helper";

const cdk = new App();

const buildConfig = Config(cdk);

// ...

Replace this with the loadConfig() function from @backpack/config. You can use the cdkLoader() to load both YAML files and CDK context variables.

ts
import { App } from "aws-cdk-lib";
import { loadConfig, cdkLoader } from "@backpack/config";

import { BuildConfig } from "../config";

const cdk = new App();

const buildConfig = await loadConfig({
  schema: BuildConfig,
  loaders: [
    cdkLoader(cdk, { configDirectory: "./cdk/config" }),
  ]
});

// ...

3. Optional: apply envSuffix where needed

If you are using the envSuffix from @ns/cdk-helper somewhere in your project, make sure to explicitly apply it where you need it.

First, add it to your config schema. This will automatically pick up -c envSuffix=foo as CDK command-line argument.

You can use Zod transforms to make sure it converts to a certain case, such as kebab-case:

ts
import { z } from "zod";
import { kebabCase } from "change-case";

export type BuildConfig = z.infer<typeof BuildConfig>;
export const BuildConfig = z.object({
  // ...
  envSuffix: z.string()
    .transform(it => kebabCase(it))
    .optional(),
});

Everywhere you are relying on the App property, and you want to apply the suffix, make sure to do so explicitly.

For example:

ts
// add the suffix if needed:
const stackPrefix = buildConfig.envSuffix
  ? `${buildConfig.App}-${buildConfig.envSuffix}`
  : `${buildConfig.App}`;

// apply it:
const appStack = new AppStack(cdk, `${stackPrefix}-app-stack`, {
  env: {
    region: buildConfig.AWSRegion,
    account: buildConfig.AWSAccount,
  },
  buildConfig: buildConfig,
  tags: buildConfig.Tags,
  // ...
});

4: Double-check your tags

In contrast to @ns/cdk-helper, the @backpack/config library contains no CDK-specific logic such as validating CDK tags. If you still want to validate your tags, you can leverage Zod validation for this, by adjusting your schema:

ts
export const BuildConfig = z.object({
  // ...
  Tags: z.object({
    App: z.string(),
    Team: z.string(),
    // ...
  })
});

Interfaces

Functions

Loaders

Utilities