@env42/zod-helpers v0.0.1
env42
: Type-Safe Configuration for life, the Universe and Everything
Welcome to env42
, the closest thing to a Towel when trying to achieve type-safe, validated configurations from environment variables in TypeScript projects.
env42
is designed to be your loyal companion, ensuring that your configurations remain save. It's perfectly tailored to keep you safe while you navigate the vast universe of Typescript, both in the browser and in Node.js.
Built o top of Zod
, env42
inherits a wealth of features and benefits that enhance the validation and type-checking capabilities of your configuration setup, ultimately making your coding experience more robust and reliable.
env42
ā¤ļøZod
: A powerful alliance that unlocks the full potential of type-safe configurations!
Free Perks
āØ Type Safety: Ensure your configurations are type-safe, preventing runtime errors and improving code reliability.
š Browser and Node.js Compatibility: Seamlessly handle configurations in both browser and Node.js environments.
š Automatic Templates: Keep your configurations in sync with your codebase by automatically generating deterministic configuration templates.
š Secure Validation: Leverage the power of the Zod validation library to enforce strict validation rules and ensure the integrity of your configurations.
š§ Easy Configuration Setup: Define your configuration schema using Zod's simple and intuitive Api, making it a breeze to set up and manage your environment variables..
š Efficient Development: Boost productivity by eliminating guesswork. Generate automatic configuration template files and have the confidence of Typescript and even Unit Tests to have mathematical certainty that you're not breaking things/
š Efficient Communication: Your configuration declaratin becomes a statement of intentions that can be used to clearly communicate with everyone in the team. env42
makes it impossible to get Config wrong.
š Seamless Integration: Easily integrate env42
into existing or new TypeScript projects without disrupting your workflow.
Remember, with `Env42e, you'll have all the answers to the universe type-safe configurations, making your coding journey through the universe a delightful and reliable experience!
š Installing env42
: Preparing for an Interstellar Configuration Adventure š
To install env42
and embark on your adventure, simply use your favorite package manager:
pnpm install @env42/core
Or perhaps, if you like throwing away hard-drive money and don't give two sh*ts about sustainability or the environment, then yeah, you can very well use
yarn
ornpm
. Go ahead, footguns are both legal and a fundamental right in every single country in the world š.
šš Grab Your Towel! Getting Started with the env42
Configuration Guide
env42
is like your own personal guide through the cosmic labyrinth of environment configurations. Here's how we get started.
1. Declare your Configuration Schema
First, you declare your Configuration as a Zod Schema. Take your chance and infer it's Type from it:
export const ExpressConfigSchema = z.object({
hostName: z.coerce.string(),
port: z.coerce.number(),
options: z.object({
autoStart: z.coerce
.boolean()
.nullish()
.default(true),
}),
});
// Take your chance and infer a Type from it
export type ExpressConfig = z.infer<
typeof ExpressConfigSchema
>;
2. Link your Environment Variables
Once that is out of the way, declare a Zod Enum for all the environment variables want to have in your system and a Map that links them both. Notice how we leverage Typescript to ensure no typos are ever possible:
export const ExpressEnvVarNames = z.enum([
'EXPRESS_HOST',
'EXPRESS_PORT',
'EXPRESS_AUTO_START',
]);
// Also infer the type of the Enum
export type ExpressEnvVarNames = z.infer<
typeof MockedConfigEnvVarsSchema
>;
export const expressConfigEnvVarsMap: Record<
FieldPath<MockedConfig>,
MockedEnvVarNames
> = {
hostName: MockedEnvVarsEnum.EXPRESS_HOST,
port: MockedEnvVarsEnum.EXPRESS_PORT,
// Red squigly lines here if any of the keys are missing or have a typo.
'options.autoStart': MockedEnvVarsEnum.EXPRESS_AUTO_START,
};
š„° Notice how we can map deep paths using dot notation and still be type-safe by using
FieldPaths
. Kudos toreact-hook-forms
for the inspiration and for the permissive license that allows us to just copy the types over and not need to depend on the entire library. You can also import that type from here and use it for your own purposes. Perhaps down the line those types can be migrated into their own general purpose package. One can dream.
3. Create the Config Singleton
Once that's in place, we can create a single helper function that can load and validate the entire Configuration from the Environment Variables available at the current runtime .
// Loading configs is not cheap. Let's use a module Singleton
let expressConfig: ExpressConfig | null = null;
export const getExpressConfig = (env: EnvKeys<ExpressEnvVarNames> = process.env) => {
// we only load it if we already have it
if (!expressConfig) {
expressConfig = ConfigHelpers.loadValidatedSchema(
ExpressConfigSchema,
expressConfigEnvVarsMap,
env,
);
}
return expressConfig;
}
4. Use it Anywhere!
Now, wherever we want to use the configuration, we just import that getExpressConfig
function we created and use it as if it's no big deal. Just call it and use it as any other function.
import { getExpressConfig } from './config';
const config = getExpressConfig();
// Now we can use it as any other object
const app = express();
if (config.options.autoStart) {
app.listen(config.port, config.hostName);
}
š Notice how we don't need to pass any parameters to the function. That's because we're using the default
process.env
object. If you want to use a different environment, you can pass it as a parameter. That's useful for testing, for example.
š Advanced Usage: Unravelling the Config Galaxy with Precision
Just like any other Towel, env42
is designed to be a simple, yet powerful tool to help you manage your app configurations. And we know that in real life configuration is much better described by a tree of objects, not just a single object. So, let's see how we can use env42
to manage a more complex configuration.
In the example below, we also add a CORS configuration section to our Configuration:
export const CorsConfigSchema = z.object({
origin: z.coerce.string(),
headers: z.coerce.string()
.optional()
.default('*'),
});
export type CorsConfig = z.infer<typeof CorsConfigSchema>;
export const CorsEnvVarsSchema = z.enum([
'CORS_ORIGIN',
'CORS_HEADERS',
]);
export type CorsConfigEnvVarNames = z.infer<
typeof CorsEnvVarsSchema
>;
export const corsConfigEnvVarsMap: Record<
FieldPath<CorsConfig>,
CorsConfigEnvVarNames
> = {
origin: CorsEnvVarsSchema.Enum.CORS_ORIGIN,
headers: CorsEnvVarsSchema.Enum.CORS_HEADERS,
};
let corsConfig: CorsConfig | null = null;
export const loadCorsConfig = (
env: EnvKeys<CorsConfigEnvVarNames>,
) => {
if (!corsConfig) {
corsConfig = ConfigHelpers.loadValidatedSchema(
CorsConfigSchema,
corsConfigEnvVarsMap,
env,
);
}
return corsConfig;
}
Now, we can create a separate configuration declaration to merge all the leaves of our tree into a single object:
export const AppConfigSchema = z.object({
express: ExpressConfigSchema,
cors: CorsConfigSchema,
});
// I know for a fact I will never get over how neat `z.infer` is
export type AppConfig = z.infer<typeof AppConfigSchema>;
Once we have that in place, we create a merged enum of all the environment variables we need to load:
export const AppConfigEnvVarsSchema = z.enum([
...ExpressEnvVarsSchema.options,
...CorsEnvVarsSchema.options,
]);
export type AppConfigEnvVarNames = z.infer<
typeof AppConfigEnvVarsSchema
>;
And finally, create a function that can get the entire configuration for us:
let appConfig: AppConfig | null = null;
export const getAppConfig = (
env: EnvKeys<AppConfigEnvVarNames> = process.env as any,
): AppConfig => {
if (!appConfig) {
appConfig = {
api: loadApiConfig(env),
cors: loadCorsConfig(env),
};
}
return appConfig;
};
Now, whenever we want to use it, we can just import the getAppConfig
function and use it as usual:
import { getAppConfig } from './config';
const config = getAppConfig();
// Now we can use it as any other object
const app = express();
if (config.express.options.autoStart) {
app.listen(
config.express.port,
config.express.hostName
);
}
app.use(
cors({
origin: config.cors.origin,
headers: config.cors.headers,
})
);
šāØ Config Template Magic: Creating a Hitchhiker's Guide to Your Configuration Universe
Now that we have our configuration in place, we can use it to generate a template for our configuration. That way, we can easily generate up to date documentation about our configuration and make it easy for other developers to know what they need to do to get the app running.
If your configuration is simple and has no child sections, you already have everything in place, so let's begin:
1. Create a Config Template scripts
You'll need to create a ts file somewhere in your project. In our example, we'll put it at scripts/generateConfigTemplate.ts
. In that script file, you can use ConfigTemplateGenerator.generateConfigFile
.
ConfigTemplateGenerator.generateConfigFile<
typeof CorsConfigSchema,
CorsConfigEnvVarNames
>({
filePath: `${__dirname}/../.env.example`,
ConfigSchema: CorsConfigSchema,
configMap: corsConfigEnvVarsMap,
example: {
CORS_ORIGIN: 'http://localhost:3000',
CORS_HEADERS: '*',
},
});
2. Add the Script to your package.json scripts
Now, you can add the script to your package.json
scripts.
{
"scripts": {
"generate:config-template": "tsx scripts/generateConfigTemplate",
"postinstall": "pnpm generate:config-example"
}
}
ā¹ļø Notice how we add the template generating command to the
postinstall
hook. That should give any new developers an nice, up to date template to start with when they install the project, even if we manage to to somehow get the template outdate. š” And yes, you can use whatever script runner you want. But usingtsx
overts-node
is highly recommended
3. Run the Script
Now, you can run the script and it will generate a template for you. For our last example, it would generate a .env.example
file at the root of the project with the following contents:
# config.origin
CORS_ORIGIN="http://localhost:3000"
# config.headers
CORS_HEADERS="*"
Generating a Config Template for a Complex Configuration
In case we have a complx configuration, the only extra step necessary is to export a merged map. The good thing is that we don't need to do that by hand as env42
provides a helper function for precisely that reason: `ConfigHelpers.mergeConfigMap
// config.ts
export const appConfigEnvVarsMap = ConfigHelpers.mergeConfigMap({
express: expressConfigEnvVarsMap,
cors: corsConfigEnvVarsMap,
});
And that's it! Now, you can use the appConfigEnvVarsMap
to generate a template for your entire configuration.
// scripts/generateConfigTemplate.ts
ConfigTemplateGenerator.generateConfigFile<
typeof AppConfigSchema,
AppConfigEnvVarNames
>({
filePath: `${__dirname}/../.env.example`,
ConfigSchema: AppConfigSchema,
configMap: appConfigEnvVarsMap,
example: {
API_HOST_NAME: 'localhost',
API_PORT: '4000',
API_AUTO_START: 'true',
CORS_ORIGIN: 'http://localhost:3000',
CORS_HEADERS: '*',
},
});
Now, whenever you install anything it will create a .env.example
at the project root with the following contents:
# config.express.hostName
API_HOST_NAME="localhost"
# config.express.port
API_PORT=4000
# config.express.options.autoStart
API_AUTO_START=true
# config.cors.origin
CORS_ORIGIN="http://localhost:3000"
# config.cors.headers
CORS_HEADERS="*"
šš Navigating the Front-End Galaxy: Unleashing env42
in the Browser Universe
env42
is not only useful for Node.js projects. You can also use it in the browser, with just a small caveat: In the browser, you can't use environment variables because a server environment is not present at runtime in front-end Projects. However, you can use the same configuration schema to generate a configuration object that you can use in your production front-end code.
Most Meta-Frameworks like Next.js DO load environment variables while running in development mode and statically replace them in the output during build time. However, that's not always the case. In Next.js export
mode, for example, the framework will only replace environment variables that have been statically called in the code, like in process.env.NEXT_PUBLIC_SOME_VAR
. If you have a dynamic environment variable, you'll need to use a different approach. The sad part is that at env42
all we do is dynamic access.
To circumvent this problem, we can have a script that extracts the configuration at build time and saves it to a file that we can import at runtime. That way, all the configuration we need will be persisted into an external module that can be imported at runtime. There's no much point in providing it from env42
because of how easy it is to implement yourself and how you might want to customize it to your needs. Take a look at the following example:
// scripts/lockConfig.ts
import fs from 'fs';
import { getAppConfig } from '@/config';
/**
* Generates a .env.json file from the current environment variables.
*
* https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables#bundling-environment-variables-for-the-browser
*/
const config = getAppConfig();
const formatttedContent = JSON.stringify(config, null, 2);
fs.writeFileSync(
`${__dirname}/../.env.json`,
`${formatttedContent}\n`,
);
Now, you can run this script before building your project. For better usability, we recommend creating an exclusive script to be ran in CI before building your project. That way, you can ensure that the configuration is always up to date. For example, in Next.js, you can add the following script to your package.json
:
{
"scripts": {
"config:lock": "rm -f .env.json && tsx scripts/lockConfig",
"build": "...",
"build:ci": "pnpm config:lock && pnpm build",
}
}
Now we're generating the static config file. Only thing remaining is to make some changes in our getAppConfig
to take the presence of that file into account:
let appConfig: AppConfig | null = null;
export const getAppConfig = (
env: EnvKeys<AppConfigEnvVarNames> = process.env as any,
): AppConfig => {
if (!appConfig) {
appConfig = loadAppConfig(env) as any;
}
return appConfig as any;
};
const loadAppConfig = (env: EnvKeys<AppConfigEnvVarNames>) => {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const result = require('@/../.env.json') as any;
return result;
} catch (err) {}
return loadAppConfigFromEnvironment(env);
};
export const loadAppConfigFromEnvironment = (
env: EnvKeys<AppConfigEnvVarNames> = process.env as any,
): AppConfig => ({
express: loadExpressConfig(env),
cors: loadCorsConfig(env),
});
11 months ago