Skip to content

Instantly share code, notes, and snippets.

@cullylarson
Last active July 8, 2023 00:15
Show Gist options
  • Save cullylarson/d710be62559c593c0f7dd6680e22daca to your computer and use it in GitHub Desktop.
Save cullylarson/d710be62559c593c0f7dd6680e22daca to your computer and use it in GitHub Desktop.
Config pattern

This gives us:

  • Type safety
  • Normalized conversation of values with envToBoolean/String/Number
  • Nested/complex config objects
  • Validation

But we don't need to rely on zod to coerce the values.

One issue this doesn't resolve is different requirements per stage (e.g. some values not being required for development). But you could use this pattern to create per-stage schemas. Zod provides some useful schema "extension/modification" functions (e.g. z.extend, z.pick, z.omit). So you could create a base schema with all the shared config requirements and then extend to create the per-stage schemas.

const stages = ['production', 'development', 'test'] as const;
type Stage = (typeof stages)[number];
function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
return value !== null && value !== undefined;
}
function getStage(stages: Stage[]) {
if (!stages.length) return 'development';
for (const stage of stages) {
// if any of the provided stages is not production, assume we aren't in production
if (stage !== 'production') {
return stage;
}
}
return stages[0];
}
function isStage(potentialStage: string): potentialStage is Stage {
return stages.includes(potentialStage as Stage);
}
function envToBoolean(value: string | undefined, defaultValue = false): boolean {
if (value === undefined || value === '') {
return defaultValue;
}
// Explicitly test for true instead of false because we don't want to turn
// something on by accident.
return ['1', 'true'].includes(value.trim().toLowerCase()) ? true : false;
}
function envToString(value: string | undefined, defaultValue = '') {
return value === undefined ? defaultValue : value;
}
function envToNumber(value: string | undefined, defaultValue: number): number {
return value === undefined || value === '' ? defaultValue : Number(value);
}
const stage = getStage(
[process.env.NODE_ENV, process.env.APP_ENV].filter(notEmpty).filter(isStage)
);
const configSchema = z.object({
stage: z.enum(stages),
port: z.number().positive(),
ci: z.object({
isCi: z.boolean(),
isPullRequest: z.boolean(),
}),
database: z.object({
url: z.string().nonempty(),
shouldMigrate: z.boolean(),
}),
git: z.object({
commit: z.string(),
}),
});
export type Config = z.infer<typeof configSchema>;
export const config = configSchema.parse({
stage,
port: envToNumber(process.env.PORT, 0),
ci: {
isCi: envToBoolean(process.env.CI),
isPullRequest: envToBoolean(process.env.IS_PULL_REQUEST),
},
database: {
url: envToString(process.env.DATABASE_URL),
shouldMigrate: envToBoolean(process.env.SHOULD_MIGRATE),
},
git: {
commit: envToString(process.env.FC_GIT_COMMIT_SHA || process.env.RENDER_GIT_COMMIT),
},
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment