Skip to content

Commit 2c29d59

Browse files
author
A.P.A. Slaa
committed
feat: decouple env schema validation from internal helper
1 parent 4d79ae8 commit 2c29d59

4 files changed

Lines changed: 158 additions & 4 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,31 @@ const runtimeConfig = env.config.map({
129129

130130
`env.config.safeMap()` returns `{ success, data | errors }` instead of throwing.
131131

132+
### Plug in another schema library
133+
134+
The package ships with a built-in dependency-free validator, but the root export also exposes `adaptSchema()` so you can wrap another runtime schema library later without coupling your app to an internal helper module.
135+
136+
```ts
137+
import { adaptSchema, env, validation } from "@sourceregistry/node-env";
138+
139+
const externalSchema = {
140+
safeParse(value: unknown) {
141+
return typeof value === "string"
142+
? { success: true as const, data: value }
143+
: { success: false as const, issues: [{ message: "Expected string" }] };
144+
},
145+
};
146+
147+
const ExternalString = adaptSchema<typeof externalSchema, string>(externalSchema, {
148+
safeParse(schema, value) {
149+
return schema.safeParse(value);
150+
},
151+
});
152+
153+
validation.parse(ExternalString, "ok");
154+
env.config.field("APP_NAME", ExternalString);
155+
```
156+
132157
### CSV env values
133158

134159
Use `env.schema.csv()` for comma-separated env variables:

src/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {readFileSync, existsSync} from "fs";
22
import {
3+
adaptSchema,
34
fail,
45
InferValidator,
56
InferSchemaShape,
@@ -14,8 +15,11 @@ import {
1415
ValidationFailure,
1516
ValidationResult,
1617
ValidationSuccess,
18+
ValidationAdapter,
19+
ValidationAdapterResult,
20+
ValidationIssueInput,
1721
Validator
18-
} from "./schema.helper";
22+
} from "./validation";
1923

2024
type DotEnvEntries = Record<string, string>;
2125
const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
@@ -701,17 +705,25 @@ export const env = Object.freeze({
701705
* Re-export of the generic validation primitives and supporting public types.
702706
*/
703707
export {
708+
adaptSchema,
704709
envConfig,
705710
envValidation,
711+
fail,
712+
isFailure,
713+
ok,
714+
runValidation,
706715
SchemaValidationError,
707716
validation,
708717
};
709718
export type {
710719
InferValidator,
711720
InferSchemaShape,
712721
SchemaShape,
722+
ValidationAdapter,
723+
ValidationAdapterResult,
713724
ValidationFailure,
714725
ValidationIssue,
726+
ValidationIssueInput,
715727
ValidationPath,
716728
ValidationResult,
717729
ValidationSuccess,
Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,56 @@ export type ValidationPath = Array<string | number>;
3737
* Runtime validator function.
3838
*/
3939
export type Validator<T> = (value: unknown, path?: ValidationPath) => ValidationResult<T>;
40+
4041
/**
4142
* Infers the validated type from a validator.
4243
*/
4344
export type InferValidator<V> = V extends Validator<infer T> ? T : never;
45+
4446
/**
4547
* Generic object schema shape.
4648
*/
4749
export type SchemaShape = Record<string, Validator<any>>;
50+
4851
/**
4952
* Infers the validated output from an object schema shape.
5053
*/
5154
export type InferSchemaShape<S extends SchemaShape> = {
5255
[K in keyof S]: InferValidator<S[K]>;
5356
};
5457

58+
/**
59+
* Issue input shape accepted by adapter helpers.
60+
*/
61+
export type ValidationIssueInput = {
62+
path?: string | ValidationPath;
63+
message: string;
64+
code?: string;
65+
};
66+
67+
/**
68+
* Result shape accepted by adapter helpers.
69+
*/
70+
export type ValidationAdapterResult<T> =
71+
| ValidationResult<T>
72+
| {
73+
success: true;
74+
data: T;
75+
}
76+
| {
77+
success: false;
78+
errors?: ValidationIssueInput[];
79+
issues?: ValidationIssueInput[];
80+
error?: unknown;
81+
};
82+
83+
/**
84+
* Adapter used to bridge external schema libraries into the local validator contract.
85+
*/
86+
export type ValidationAdapter<TSchema, TOutput = unknown> = {
87+
safeParse(schema: TSchema, value: unknown): ValidationAdapterResult<TOutput>;
88+
};
89+
5590
/**
5691
* Error thrown by `validation.parse()` when validation fails.
5792
*/
@@ -65,7 +100,7 @@ export class SchemaValidationError extends Error {
65100
}
66101
}
67102

68-
const toPathString = (path: ValidationPath) => {
103+
export const toPathString = (path: ValidationPath) => {
69104
if (path.length === 0) return "$";
70105
return path.reduce<string>((acc, part) => {
71106
if (typeof part === "number") return `${acc}[${part}]`;
@@ -74,6 +109,19 @@ const toPathString = (path: ValidationPath) => {
74109
}, "$");
75110
};
76111

112+
const normalizePath = (path: string | ValidationPath | undefined, fallback: ValidationPath): string => {
113+
if (typeof path === "string") {
114+
return path.startsWith("$") ? path : toPathString([...fallback, path]);
115+
}
116+
return toPathString(path ?? fallback);
117+
};
118+
119+
const normalizeIssue = (issue: ValidationIssueInput, fallback: ValidationPath): ValidationIssue => ({
120+
path: normalizePath(issue.path, fallback),
121+
message: issue.message,
122+
code: issue.code,
123+
});
124+
77125
/**
78126
* Creates a successful validation result.
79127
*/
@@ -104,6 +152,47 @@ export const isFailure = <T>(result: ValidationResult<T>): result is ValidationF
104152
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
105153
typeof value === "object" && value !== null && !Array.isArray(value);
106154

155+
/**
156+
* Normalizes adapter output into the local validation result format.
157+
*/
158+
export const normalizeAdapterResult = <T>(
159+
result: ValidationAdapterResult<T>,
160+
path: ValidationPath = []
161+
): ValidationResult<T> => {
162+
if (result.success) {
163+
return ok(result.data);
164+
}
165+
166+
if ("errors" in result && Array.isArray(result.errors) && result.errors.length > 0) {
167+
return {
168+
success: false,
169+
errors: result.errors.map((issue) => normalizeIssue(issue, path)),
170+
};
171+
}
172+
173+
if ("issues" in result && Array.isArray(result.issues) && result.issues.length > 0) {
174+
return {
175+
success: false,
176+
errors: result.issues.map((issue) => normalizeIssue(issue, path)),
177+
};
178+
}
179+
180+
const error = "error" in result ? result.error : undefined;
181+
const message = error instanceof Error
182+
? error.message
183+
: "Schema validation failed";
184+
return fail(message, path, "invalid_schema");
185+
};
186+
187+
/**
188+
* Wraps an external schema and adapter into a local validator function.
189+
*/
190+
export const adaptSchema = <TSchema, T>(
191+
schema: TSchema,
192+
adapter: ValidationAdapter<TSchema, T>
193+
): Validator<T> =>
194+
(value, path = []) => normalizeAdapterResult<T>(adapter.safeParse(schema, value), path);
195+
107196
/**
108197
* Runs a validator against a value.
109198
*/

tests/schema.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import {describe, expect, it} from "vitest";
22
import {
3+
adaptSchema,
34
fail,
45
isFailure,
56
ok,
67
runValidation,
78
SchemaValidationError,
9+
ValidationAdapterResult,
810
validation
9-
} from "../src/schema.helper";
11+
} from "../src";
1012

11-
describe("schema helper", () => {
13+
describe("validation", () => {
1214
it("exposes ok, fail, isFailure, and runValidation helpers", () => {
1315
const success = ok("value");
1416
const failure = fail("Broken", ["ROOT", 0], "bad");
@@ -196,4 +198,30 @@ describe("schema helper", () => {
196198

197199
expect(() => validation.parse(validation.number(), "nope")).toThrow(SchemaValidationError);
198200
});
201+
202+
it("adapts external safeParse-style schemas without adding a dependency", () => {
203+
const schema = {
204+
safeParse(value: unknown) {
205+
if (typeof value === "string" && value.length > 0) {
206+
return {success: true as const, data: value.toUpperCase()};
207+
}
208+
return {
209+
success: false as const,
210+
issues: [{path: [], message: "Expected non-empty string", code: "custom"}],
211+
};
212+
},
213+
};
214+
215+
const validator = adaptSchema<typeof schema, string>(schema, {
216+
safeParse(target, value): ValidationAdapterResult<string> {
217+
return target.safeParse(value);
218+
},
219+
});
220+
221+
expect(validation.parse(validator, "test")).toBe("TEST");
222+
expect(validation.safeParse(validator, "")).toEqual(expect.objectContaining({
223+
success: false,
224+
errors: [expect.objectContaining({path: "$", code: "custom"})],
225+
}));
226+
});
199227
});

0 commit comments

Comments
 (0)