Skip to content

Commit

Permalink
Use string-ts library and support zod default values (#18)
Browse files Browse the repository at this point in the history
* temp

* a bit closer

* something that seems to work

* changeset

* use type directly

* refactor

* use constantcase instead of default

* revert some tsconfig changes

* update package.json with npm stuff

* update readme

* refactor

Co-authored-by: Eivind M. Skretting <[email protected]>

* handle zod default values

---------

Co-authored-by: Eivind M. Skretting <[email protected]>
  • Loading branch information
bompi88 and ludovico authored Jan 25, 2024
1 parent 2d048bc commit b671288
Show file tree
Hide file tree
Showing 10 changed files with 130 additions and 115 deletions.
5 changes: 5 additions & 0 deletions .changeset/shaggy-trains-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@arundo/typed-env": minor
---

Use strings-ts to simplify the package.
5 changes: 5 additions & 0 deletions .changeset/tricky-trees-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@arundo/typed-env": patch
---

Types of the resolved environment is now the output of schema parse and not the input.
7 changes: 5 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Set naming convention of environment variables:

/* ... as usual ... */

export const environment = typeEnvironment(envSchema, 'camelcase');
export const environment = typeEnvironment(envSchema, { transform: 'camelcase' });
```

```ts
Expand All @@ -72,7 +72,7 @@ export const envSchema = z.object({
PORT: z.coerse.number().int().default(3000),
});

export const environment = typeEnvironment(envSchema, 'camelcase');
export const environment = typeEnvironment(envSchema, { transform: 'camelcase' });

declare global {
namespace NodeJS {
Expand Down
27 changes: 24 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,24 @@
"types",
"environment-variables",
"zod",
"env"
"env",
"transform"
],
"homepage": "https://github.com/arundo/typed-env",
"bugs": {
"url": "https://github.com/arundo/typed-env/issues"
},
"repository": {
"type": "git",
"url": "git://github.com/arundo/typed-env.git"
},
"author": {
"name": "Arundo Analytics",
"url": "https://arundo.com"
},
"files": [
"dist/"
],
"author": "Arundo Analytics",
"license": "MIT",
"peerDependencies": {
"@types/node": ">=14.0.0",
Expand All @@ -28,7 +43,13 @@
"devDependencies": {
"@changesets/cli": "^2.22.0",
"tsup": "^8.0.1",
"typescript": "^5.3.2",
"typescript": "^5.3.3",
"vitest": "^0.34.6"
},
"dependencies": {
"string-ts": "^2.0.0"
},
"publishConfig": {
"access": "public"
}
}
21 changes: 14 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 0 additions & 23 deletions src/casings.ts

This file was deleted.

26 changes: 26 additions & 0 deletions src/contracts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ZodError } from 'zod';
import { Replace, CamelKeys, ConstantKeys, KebabKeys, PascalKeys } from 'string-ts';

export type NamingConvention = 'camelcase' | 'pascalcase' | 'kebabcase' | 'constantcase' | 'default';

export type Options<TTransform, TPrefixRemoval> = {
transform?: TTransform;
formatErrorFn?: (error: ZodError) => string;
excludePrefix?: TPrefixRemoval;
};

export type ConditionalType<TTransform extends NamingConvention, TSchema> = 'default' extends TTransform
? TSchema
: 'constantcase' extends TTransform
? ConstantKeys<TSchema>
: 'camelcase' extends TTransform
? CamelKeys<TSchema>
: 'pascalcase' extends TTransform
? PascalKeys<TSchema>
: 'kebabcase' extends TTransform
? KebabKeys<TSchema>
: never;

export type PrefixRemoved<TSchema, TPrefixRemoval extends string> = {
[key in keyof TSchema as key extends string ? Replace<key, TPrefixRemoval, ''> : never]: TSchema[key];
} & {};
125 changes: 48 additions & 77 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,8 @@
import { z } from 'zod';
import { ChangeCase, NamingConvention } from './casings';
import { ZodTypeAny, ZodError } from 'zod';
import { replace, camelKeys, pascalKeys, kebabKeys, constantKeys, Replace } from 'string-ts';
import { ConditionalType, NamingConvention, Options, PrefixRemoved } from './contracts';

type RemovePrefix<P extends string, S extends string> = P extends ''
? S
: P extends `${infer O}_`
? S extends `${O}_${infer U}`
? U
: S
: S extends `${P}_${infer U}`
? U
: S;

type BaseSchema = Record<string, unknown>;

type EnvReturnType<TCase extends NamingConvention, P extends string, S extends BaseSchema> = {
[K in keyof S as ChangeCase<TCase, RemovePrefix<P, string & K>>]: S[K];
} & {};

const toCamelCase = <TSchema extends BaseSchema>(str: string & keyof TSchema): string =>
str.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());

const toPascalCase = <TSchema extends BaseSchema>(str: string & keyof TSchema): string =>
str.toLowerCase().replace(/(^[a-z]|_[a-z])/g, (_, letter) => {
return letter.startsWith('_') ? letter.replace('_', '').toUpperCase() : letter.toUpperCase();
});

const toKebabCase = <TSchema extends BaseSchema>(str: string & keyof TSchema): string =>
str.toLowerCase().replace(/_/g, '-');

const getTransformFn = <TSchema extends BaseSchema>(transform: NamingConvention) => {
switch (transform) {
case 'camelcase':
return toCamelCase;
case 'pascalcase':
return toPascalCase;
case 'kebabcase':
return toKebabCase;
default:
return (str: string & keyof TSchema) => str;
}
};

const transformKeys =
<TSchema extends BaseSchema>(transformFn: (str: keyof TSchema) => keyof TSchema) =>
(obj: TSchema) =>
Object.fromEntries(Object.entries(obj).map(([key, value]) => [transformFn(key), value])) as TSchema;

const formatError = (error: z.ZodError) =>
const formatError = (error: ZodError) =>
`Environment variable validation failed:${error.issues
.map(issue => `\n\t'${issue.path.join(',')}': ${issue.message}`)
.join(',')}`;
Expand All @@ -61,47 +17,62 @@ const getEnvironment = () => {
throw new Error('Failed to get environment object');
};

const removePrefix =
(prefix?: string) =>
<TSchema extends BaseSchema>(str: string & keyof TSchema): string =>
prefix ? (prefix.endsWith('_') ? str.replace(prefix, '') : str.replace(`${prefix}_`, '')) : str;
export function removePrefix<T extends Record<string, unknown>, L extends string>(obj: T, prefix: L) {
if (!prefix) {
return obj;
}

const removePrefixDecorator =
(prefix?: string) =>
(transform: <TSchema extends BaseSchema>(str: string & keyof TSchema) => string) =>
<TSchema extends BaseSchema>(str: string & keyof TSchema): string =>
transform(removePrefix(prefix)(str));
const res: Record<string, unknown> = {};

type Options<TTransform, TPrefixRemoval> = {
transform?: TTransform;
formatErrorFn?: (error: z.ZodError) => string;
excludePrefix?: TPrefixRemoval;
for (const key in obj) {
const transformedKey = prefix
? prefix.endsWith('_')
? replace(key, prefix, '')
: replace(key, `${prefix}_`, '')
: key;
res[transformedKey] = obj[key];
}
return res as {
[K in Extract<keyof T, string> as Replace<Extract<keyof T, string>, L, ''>]: T[K];
};
}

const changeCase = <TTransform extends NamingConvention | undefined, TSchema>(
transform: TTransform,
schema: TSchema,
) => {
switch (transform) {
case 'camelcase':
return camelKeys(schema);
case 'pascalcase':
return pascalKeys(schema);
case 'kebabcase':
return kebabKeys(schema);
case 'constantcase':
default:
return constantKeys(schema);
}
};

export const typeEnvironment = <
TSchema extends BaseSchema,
TSchema extends ZodTypeAny,
TTransform extends NamingConvention,
TPrefixRemoval extends string = '',
>(
schema: z.Schema<TSchema>,
{
transform = 'default' as TTransform,
formatErrorFn = formatError,
excludePrefix = '' as TPrefixRemoval,
}: Options<TTransform, TPrefixRemoval> = {},
schema: TSchema,
options: Options<TTransform, TPrefixRemoval> = {},
overrideEnv: Record<string, string | undefined> = getEnvironment(),
) => {
const removePrefixWrapper = removePrefixDecorator(excludePrefix);
const { transform = 'default', formatErrorFn = formatError, excludePrefix = '' as TPrefixRemoval } = options;

try {
const returnobj = schema
.transform((obj: TSchema) => {
return transformKeys(removePrefixWrapper(getTransformFn(transform)))(obj);
})
.parse(overrideEnv) as EnvReturnType<NonNullable<typeof transform>, NonNullable<typeof excludePrefix>, TSchema>;
return returnobj;
const parsed = schema.parse(overrideEnv);
type TSchemaOutput = TSchema['_output'];
const prefixRemoved = removePrefix(parsed, excludePrefix) as PrefixRemoved<TSchemaOutput, TPrefixRemoval>;
return changeCase(transform, prefixRemoved) as ConditionalType<TTransform, typeof prefixRemoved>;
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(formatErrorFn(error));
if (error instanceof ZodError) {
throw new Error(formatErrorFn ? formatErrorFn(error) : formatError(error));
}
throw new Error('Environment variable validation failed');
}
Expand Down
2 changes: 1 addition & 1 deletion test/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const overrideEnv = {
test('zod schema', () => {
const env = typeEnvironment(
z.object({
HOST: z.string(),
HOST: z.string().default('localhost'),
}),
undefined,
overrideEnv,
Expand Down

0 comments on commit b671288

Please sign in to comment.