From b671288185324f97798bfe887fcd8e334d397287 Mon Sep 17 00:00:00 2001 From: bompi88 Date: Thu, 25 Jan 2024 15:01:59 +0100 Subject: [PATCH] Use string-ts library and support zod default values (#18) * 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 * handle zod default values --------- Co-authored-by: Eivind M. Skretting --- .changeset/shaggy-trains-turn.md | 5 ++ .changeset/tricky-trees-build.md | 5 ++ .vscode/settings.json | 7 +- README.md | 4 +- package.json | 27 ++++++- pnpm-lock.yaml | 21 ++++-- src/casings.ts | 23 ------ src/contracts.ts | 26 +++++++ src/index.ts | 125 ++++++++++++------------------- test/load.test.ts | 2 +- 10 files changed, 130 insertions(+), 115 deletions(-) create mode 100644 .changeset/shaggy-trains-turn.md create mode 100644 .changeset/tricky-trees-build.md delete mode 100644 src/casings.ts create mode 100644 src/contracts.ts diff --git a/.changeset/shaggy-trains-turn.md b/.changeset/shaggy-trains-turn.md new file mode 100644 index 0000000..61fa8c3 --- /dev/null +++ b/.changeset/shaggy-trains-turn.md @@ -0,0 +1,5 @@ +--- +"@arundo/typed-env": minor +--- + +Use strings-ts to simplify the package. diff --git a/.changeset/tricky-trees-build.md b/.changeset/tricky-trees-build.md new file mode 100644 index 0000000..b745eea --- /dev/null +++ b/.changeset/tricky-trees-build.md @@ -0,0 +1,5 @@ +--- +"@arundo/typed-env": patch +--- + +Types of the resolved environment is now the output of schema parse and not the input. diff --git a/.vscode/settings.json b/.vscode/settings.json index 4c34607..1c24996 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" + } } diff --git a/README.md b/README.md index 174c34d..06fe342 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 { diff --git a/package.json b/package.json index 7e4bf7d..ae0e845 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e03c7ef..4b008d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ dependencies: '@types/node': specifier: '>=14.0.0' version: 20.10.0 + string-ts: + specifier: ^2.0.0 + version: 2.0.0 zod: specifier: '>=3.0.0' version: 3.22.4 @@ -18,10 +21,10 @@ devDependencies: version: 2.22.0 tsup: specifier: ^8.0.1 - version: 8.0.1(typescript@5.3.2) + version: 8.0.1(typescript@5.3.3) typescript: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.3.3 + version: 5.3.3 vitest: specifier: ^0.34.6 version: 0.34.6 @@ -2093,6 +2096,10 @@ packages: mixme: 0.5.10 dev: true + /string-ts@2.0.0: + resolution: {integrity: sha512-Q+WJ5tQ0AdCeWgbhe3ZqDw1v5DGac5/lmDVNbJIa/bFR7TGfB8nJ1rHQSqinZHB8wKetUfhQlfR89puRcIKZZw==} + dev: false + /string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2231,7 +2238,7 @@ packages: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} dev: true - /tsup@8.0.1(typescript@5.3.2): + /tsup@8.0.1(typescript@5.3.3): resolution: {integrity: sha512-hvW7gUSG96j53ZTSlT4j/KL0q1Q2l6TqGBFc6/mu/L46IoNWqLLUzLRLP1R8Q7xrJTmkDxxDoojV5uCVs1sVOg==} engines: {node: '>=18'} hasBin: true @@ -2264,7 +2271,7 @@ packages: source-map: 0.8.0-beta.0 sucrase: 3.34.0 tree-kill: 1.2.2 - typescript: 5.3.2 + typescript: 5.3.3 transitivePeerDependencies: - supports-color - ts-node @@ -2303,8 +2310,8 @@ packages: engines: {node: '>=8'} dev: true - /typescript@5.3.2: - resolution: {integrity: sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==} + /typescript@5.3.3: + resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} engines: {node: '>=14.17'} hasBin: true dev: true diff --git a/src/casings.ts b/src/casings.ts deleted file mode 100644 index 57696a6..0000000 --- a/src/casings.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type NamingConvention = 'camelcase' | 'pascalcase' | 'kebabcase' | 'default'; - -export type SnakeToCamelCase = S extends `${infer T}_${infer U}` - ? `${Lowercase}${Capitalize>}` - : Lowercase; - -export type SnakeToPascalCase = S extends `${infer T}_${infer U}` - ? `${Capitalize>}${SnakeToPascalCase}` - : Capitalize>; - -export type SnakeToKebabCase = S extends `${infer T}_${infer U}` - ? `${Lowercase}-${Lowercase>}` - : Lowercase; - -export type ChangeCase = 'default' extends T - ? S - : 'camelcase' extends T - ? SnakeToCamelCase - : 'pascalcase' extends T - ? SnakeToPascalCase - : 'kebabcase' extends T - ? SnakeToKebabCase - : S; diff --git a/src/contracts.ts b/src/contracts.ts new file mode 100644 index 0000000..ba86949 --- /dev/null +++ b/src/contracts.ts @@ -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 = { + transform?: TTransform; + formatErrorFn?: (error: ZodError) => string; + excludePrefix?: TPrefixRemoval; +}; + +export type ConditionalType = 'default' extends TTransform + ? TSchema + : 'constantcase' extends TTransform + ? ConstantKeys + : 'camelcase' extends TTransform + ? CamelKeys + : 'pascalcase' extends TTransform + ? PascalKeys + : 'kebabcase' extends TTransform + ? KebabKeys + : never; + +export type PrefixRemoved = { + [key in keyof TSchema as key extends string ? Replace : never]: TSchema[key]; +} & {}; diff --git a/src/index.ts b/src/index.ts index 8977e66..48e7d5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 '' - ? S - : P extends `${infer O}_` - ? S extends `${O}_${infer U}` - ? U - : S - : S extends `${P}_${infer U}` - ? U - : S; - -type BaseSchema = Record; - -type EnvReturnType = { - [K in keyof S as ChangeCase>]: S[K]; -} & {}; - -const toCamelCase = (str: string & keyof TSchema): string => - str.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); - -const toPascalCase = (str: string & keyof TSchema): string => - str.toLowerCase().replace(/(^[a-z]|_[a-z])/g, (_, letter) => { - return letter.startsWith('_') ? letter.replace('_', '').toUpperCase() : letter.toUpperCase(); - }); - -const toKebabCase = (str: string & keyof TSchema): string => - str.toLowerCase().replace(/_/g, '-'); - -const getTransformFn = (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 = - (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(',')}`; @@ -61,47 +17,62 @@ const getEnvironment = () => { throw new Error('Failed to get environment object'); }; -const removePrefix = - (prefix?: string) => - (str: string & keyof TSchema): string => - prefix ? (prefix.endsWith('_') ? str.replace(prefix, '') : str.replace(`${prefix}_`, '')) : str; +export function removePrefix, L extends string>(obj: T, prefix: L) { + if (!prefix) { + return obj; + } -const removePrefixDecorator = - (prefix?: string) => - (transform: (str: string & keyof TSchema) => string) => - (str: string & keyof TSchema): string => - transform(removePrefix(prefix)(str)); + const res: Record = {}; -type Options = { - 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 as Replace, L, ''>]: T[K]; + }; +} + +const changeCase = ( + 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, - { - transform = 'default' as TTransform, - formatErrorFn = formatError, - excludePrefix = '' as TPrefixRemoval, - }: Options = {}, + schema: TSchema, + options: Options = {}, overrideEnv: Record = 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, TSchema>; - return returnobj; + const parsed = schema.parse(overrideEnv); + type TSchemaOutput = TSchema['_output']; + const prefixRemoved = removePrefix(parsed, excludePrefix) as PrefixRemoved; + return changeCase(transform, prefixRemoved) as ConditionalType; } 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'); } diff --git a/test/load.test.ts b/test/load.test.ts index f61032c..94ce723 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -13,7 +13,7 @@ const overrideEnv = { test('zod schema', () => { const env = typeEnvironment( z.object({ - HOST: z.string(), + HOST: z.string().default('localhost'), }), undefined, overrideEnv,