Skip to content

Commit 10c4077

Browse files
committed
feat(schema): add universal Zod/TypeBox schema validator
Adds three new public modules: - `@socketsecurity/lib/schema/validate` — non-throwing validator. Returns `{ ok: true, value } | { ok: false, errors }` with normalized `{ path, message }` issues. Accepts any Zod-shaped schema (duck-typed on `.safeParse`); internally also recognizes TypeBox for socket-lib's own use (e.g. `src/ipc.ts` stub validation). - `@socketsecurity/lib/schema/parse` — throwing twin for fail-fast trust boundaries (app startup, config files). Summarizes all issues into a single Error message. - `@socketsecurity/lib/schema/types` — shared types: `Schema<T>`, `ParseResult<T>`, `ValidateResult<T>`, `ValidationIssue`, and `Infer<S>` (which unwraps Zod v3/v4 and TypeBox output shapes). No runtime dependency on zod — consumers bring their own.
1 parent 21cfb47 commit 10c4077

5 files changed

Lines changed: 356 additions & 1 deletion

File tree

docs/api-index.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Each entry links to the source module and shows the first sentence of its `@file
55

66
> Regenerate with `node scripts/fix/generate-api-index.mts` after adding or removing exports. Do not edit this file by hand.
77
8-
**Jump to:** [Top-level](#top-level) · [argv/](#argv) · [constants/](#constants) · [cover/](#cover) · [dlx/](#dlx) · [effects/](#effects) · [env/](#env) · [json/](#json) · [packages/](#packages) · [paths/](#paths) · [releases/](#releases) · [stdio/](#stdio) · [themes/](#themes) · [validation/](#validation)
8+
**Jump to:** [Top-level](#top-level) · [argv/](#argv) · [constants/](#constants) · [cover/](#cover) · [dlx/](#dlx) · [effects/](#effects) · [env/](#env) · [json/](#json) · [packages/](#packages) · [paths/](#paths) · [releases/](#releases) · [schema/](#schema) · [stdio/](#stdio) · [themes/](#themes) · [validation/](#validation)
99

1010
## Top-level
1111

@@ -193,6 +193,14 @@ Each entry links to the source module and shows the first sentence of its `@file
193193
| [`@socketsecurity/lib/releases/github`](../src/releases/github.ts) | GitHub release download utilities. |
194194
| [`@socketsecurity/lib/releases/socket-btm`](../src/releases/socket-btm.ts) | Socket-btm release download utilities. |
195195

196+
## schema/
197+
198+
| Subpath | Description |
199+
| ------------------------------------------------------------------ | ------------------------------------------ |
200+
| [`@socketsecurity/lib/schema/parse`](../src/schema/parse.ts) | Throwing twin of `validateSchema`. |
201+
| [`@socketsecurity/lib/schema/types`](../src/schema/types.ts) | Shared types for schema validation. |
202+
| [`@socketsecurity/lib/schema/validate`](../src/schema/validate.ts) | Universal schema validator — non-throwing. |
203+
196204
## stdio/
197205

198206
| Subpath | Description |

package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,18 @@
547547
"types": "./dist/releases/socket-btm.d.ts",
548548
"default": "./dist/releases/socket-btm.js"
549549
},
550+
"./schema/parse": {
551+
"types": "./dist/schema/parse.d.ts",
552+
"default": "./dist/schema/parse.js"
553+
},
554+
"./schema/types": {
555+
"types": "./dist/schema/types.d.ts",
556+
"default": "./dist/schema/types.js"
557+
},
558+
"./schema/validate": {
559+
"types": "./dist/schema/validate.d.ts",
560+
"default": "./dist/schema/validate.js"
561+
},
550562
"./sea": {
551563
"types": "./dist/sea.d.ts",
552564
"default": "./dist/sea.js"

src/schema/parse.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @fileoverview Throwing twin of `validateSchema`.
3+
*
4+
* Use `parseSchema(schema, data)` for fail-fast trust boundaries (app
5+
* startup, config files, internal assertions). Use the non-throwing
6+
* `validateSchema` for recoverable input (form fields, API request bodies,
7+
* anywhere errors need to surface to a user).
8+
*
9+
* @example
10+
* ```ts
11+
* import { z } from 'zod'
12+
* import { parseSchema } from '@socketsecurity/lib/schema/parse'
13+
*
14+
* const Config = z.object({ host: z.string(), port: z.number() })
15+
* const config = parseSchema(Config, json) // throws on invalid
16+
* ```
17+
*/
18+
19+
import { validateSchema } from './validate'
20+
import type { Infer } from './types'
21+
22+
/**
23+
* Parse `data` against `schema` and return the validated value.
24+
*
25+
* @throws {Error} When validation fails. The message lists all issues as
26+
* `path: message, path: message, ...`. Use `validateSchema` if you need
27+
* structured access to the error list.
28+
*/
29+
export function parseSchema<S>(schema: S, data: unknown): Infer<S> {
30+
const result = validateSchema(schema, data)
31+
if (result.ok) {
32+
return result.value
33+
}
34+
const summary = result.errors
35+
.map(e => `${e.path.join('.') || '(root)'}: ${e.message}`)
36+
.join(', ')
37+
throw new Error(`Validation failed: ${summary}`)
38+
}

src/schema/types.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* @fileoverview Shared types for schema validation.
3+
*
4+
* `Schema<T>` is the Zod-shaped duck-type contract — any validator with
5+
* a `.safeParse(data)` method returning `{ success, data?, error? }`
6+
* satisfies it. socket-lib detects Zod (v3 and v4) structurally via this
7+
* interface; consumers bring their own Zod.
8+
*
9+
* `ValidateResult<T>` / `ValidationIssue` / `Infer<S>` / `AnySchema` are
10+
* the normalized shapes produced by `@socketsecurity/lib/schema/validate`
11+
* and `@socketsecurity/lib/schema/parse`.
12+
*/
13+
14+
/**
15+
* Result of a Zod-shaped schema's `.safeParse()` call.
16+
*
17+
* @template T - The expected type of the parsed data
18+
*/
19+
export interface ParseResult<T> {
20+
/** Indicates whether parsing was successful */
21+
success: boolean
22+
/** Parsed and validated data (only present when `success` is `true`) */
23+
data?: T | undefined
24+
/** Error information (only present when `success` is `false`) */
25+
error?: unknown
26+
}
27+
28+
/**
29+
* Zod-shaped duck-type for any validator exposing `safeParse` / `parse`.
30+
*
31+
* @template T - The expected output type after validation
32+
*
33+
* @example
34+
* ```ts
35+
* import { z } from 'zod'
36+
*
37+
* const userSchema = z.object({ name: z.string(), age: z.number() })
38+
*
39+
* // Schema satisfies this interface
40+
* const schema: Schema<User> = userSchema
41+
* const result = schema.safeParse({ name: 'Alice', age: 30 })
42+
* ```
43+
*/
44+
export interface Schema<T = unknown> {
45+
/** Non-throwing parse. */
46+
safeParse(data: unknown): ParseResult<T>
47+
/** Throwing parse. */
48+
parse(data: unknown): T
49+
/** Optional schema name for debugging. */
50+
_name?: string | undefined
51+
}
52+
53+
/**
54+
* Internal structural shape of a Zod v4 schema — carries the inferred
55+
* output type on `_zod.output`. Used for type-only detection in `Infer<S>`.
56+
*
57+
* @internal
58+
*/
59+
interface ZodV4LikeSchema<O = unknown> {
60+
_zod: { output: O }
61+
safeParse(data: unknown): unknown
62+
}
63+
64+
/**
65+
* Internal structural shape of a Zod v3 schema — carries the inferred
66+
* output type on `_output`. Used for type-only detection in `Infer<S>`.
67+
*
68+
* @internal
69+
*/
70+
interface ZodV3LikeSchema<O = unknown> {
71+
_output: O
72+
safeParse(data: unknown): unknown
73+
}
74+
75+
/**
76+
* Internal structural shape of a TypeBox `TSchema` — carries the inferred
77+
* output type on the phantom `static` field. Only used inside socket-lib
78+
* for type-only detection in `Infer<S>`; external callers should pass
79+
* Zod schemas.
80+
*
81+
* @internal
82+
*/
83+
interface TypeBoxLikeSchema {
84+
static: unknown
85+
}
86+
87+
/**
88+
* Any schema kind the validators accept.
89+
*/
90+
export type AnySchema =
91+
| ZodV4LikeSchema<unknown>
92+
| ZodV3LikeSchema<unknown>
93+
| TypeBoxLikeSchema
94+
| Schema<unknown>
95+
96+
/**
97+
* Infer the validated output type from any supported schema kind.
98+
*
99+
* Order matters: TypeBox schemas carry a phantom `static` field, so we
100+
* check for TypeBox before falling through to Zod and the duck-type.
101+
*/
102+
export type Infer<S> = S extends { static: infer Static }
103+
? Static
104+
: S extends { _zod: { output: infer O } }
105+
? O
106+
: S extends { _output: infer O }
107+
? O
108+
: S extends Schema<infer T>
109+
? T
110+
: unknown
111+
112+
/**
113+
* A single normalized validation error.
114+
*/
115+
export interface ValidationIssue {
116+
/** Array path into the value (e.g. `['user', 'age']`). */
117+
path: Array<string | number>
118+
/** Human-readable description of the failure. */
119+
message: string
120+
}
121+
122+
/**
123+
* Tagged-union result of `validateSchema`. Callers narrow on `ok`.
124+
*/
125+
export type ValidateResult<T> =
126+
| { ok: true; value: T }
127+
| { ok: false; errors: ValidationIssue[] }

src/schema/validate.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/**
2+
* @fileoverview Universal schema validator — non-throwing.
3+
*
4+
* Accepts any Zod-shaped schema (`.safeParse`-exposing) and returns a tagged
5+
* result `{ ok: true, value } | { ok: false, errors }` with normalized
6+
* `{ path, message }` issues. No runtime dependency on `zod` — detection
7+
* is purely structural.
8+
*
9+
* @internal
10+
* socket-lib additionally recognizes TypeBox schemas for its own internal
11+
* use (e.g. `src/ipc.ts`'s stub-file validation). That path is not a
12+
* supported consumer API.
13+
*
14+
* @example
15+
* ```ts
16+
* import { z } from 'zod'
17+
* import { validateSchema } from '@socketsecurity/lib/schema/validate'
18+
*
19+
* const User = z.object({ name: z.string() })
20+
* const r = validateSchema(User, data)
21+
* if (r.ok) r.value.name // string
22+
* else r.errors // ValidationIssue[]
23+
* ```
24+
*/
25+
26+
import type {
27+
Infer,
28+
ParseResult,
29+
ValidateResult,
30+
ValidationIssue,
31+
} from './types'
32+
33+
/**
34+
* Detect a TypeBox schema structurally: object with a symbol key whose
35+
* description is `'TypeBox.Kind'`, holding a string value.
36+
*
37+
* @internal
38+
*/
39+
function isTypeBoxSchema(schema: unknown): boolean {
40+
if (schema === null || typeof schema !== 'object') {
41+
return false
42+
}
43+
for (const sym of Object.getOwnPropertySymbols(schema)) {
44+
if (sym.description === 'TypeBox.Kind') {
45+
return typeof (schema as Record<symbol, unknown>)[sym] === 'string'
46+
}
47+
}
48+
return false
49+
}
50+
51+
/**
52+
* Normalize a TypeBox `ValueError` iterator into plain issues.
53+
* TypeBox paths are JSON Pointers (`/user/0/name`); convert to arrays.
54+
*
55+
* @internal
56+
*/
57+
function normalizeTypeBoxErrors(
58+
errors: Iterable<{ path: string; message: string }>,
59+
): ValidationIssue[] {
60+
const out: ValidationIssue[] = []
61+
for (const err of errors) {
62+
const segs = err.path.split('/').filter(Boolean)
63+
out.push({
64+
path: segs.map(s => {
65+
const n = Number(s)
66+
return Number.isInteger(n) && String(n) === s ? n : s
67+
}),
68+
message: err.message,
69+
})
70+
}
71+
return out
72+
}
73+
74+
/**
75+
* Normalize a Zod error object (v3 or v4) into plain issues.
76+
* Both versions expose `.issues: Array<{ path, message }>`.
77+
*
78+
* @internal
79+
*/
80+
function normalizeZodError(err: unknown): ValidationIssue[] {
81+
if (err === null || typeof err !== 'object') {
82+
return [{ path: [], message: String(err) }]
83+
}
84+
const issues = (err as { issues?: unknown }).issues
85+
if (!Array.isArray(issues)) {
86+
return [{ path: [], message: 'Unknown validation error' }]
87+
}
88+
return issues.map(issue => {
89+
const i = issue as {
90+
path?: Array<string | number>
91+
message?: string
92+
}
93+
return {
94+
path: Array.isArray(i.path) ? i.path : [],
95+
message: typeof i.message === 'string' ? i.message : 'Invalid value',
96+
}
97+
})
98+
}
99+
100+
/**
101+
* Validate `data` against a Zod-style `schema`. Non-throwing.
102+
*
103+
* The return type narrows `value` to `Infer<S>`, so callers get
104+
* `z.infer<typeof S>` with no casts. Errors are normalized to
105+
* `{ path, message }` regardless of the underlying validator.
106+
*
107+
* @throws {TypeError} When `schema` is not a recognized validator kind.
108+
*/
109+
export function validateSchema<S>(
110+
schema: S,
111+
data: unknown,
112+
): ValidateResult<Infer<S>> {
113+
// Internal TypeBox path: socket-lib uses TypeBox schemas in a few
114+
// places (e.g. src/ipc.ts), detected here via the structural
115+
// `[Kind]: string` marker. Not a supported consumer API. The runtime
116+
// is loaded lazily from the bundled external under `src/external/`.
117+
if (isTypeBoxSchema(schema)) {
118+
// eslint-disable-next-line @typescript-eslint/no-require-imports
119+
const { Value } = require('../external/@sinclair/typebox/value') as {
120+
Value: {
121+
Check(schema: unknown, value: unknown): boolean
122+
Errors(
123+
schema: unknown,
124+
value: unknown,
125+
): Iterable<{ path: string; message: string }>
126+
}
127+
}
128+
if (Value.Check(schema, data)) {
129+
return { ok: true, value: data as Infer<S> }
130+
}
131+
return {
132+
ok: false,
133+
errors: normalizeTypeBoxErrors(Value.Errors(schema, data)),
134+
}
135+
}
136+
137+
// Zod / Schema<T> duck-type path: any object exposing `.safeParse`.
138+
if (
139+
schema !== null &&
140+
typeof schema === 'object' &&
141+
typeof (schema as { safeParse?: unknown }).safeParse === 'function'
142+
) {
143+
const result = (
144+
schema as unknown as {
145+
safeParse(
146+
data: unknown,
147+
):
148+
| ParseResult<unknown>
149+
| { success: true; data: unknown }
150+
| { success: false; error: unknown }
151+
}
152+
).safeParse(data)
153+
154+
if ((result as { success: boolean }).success === true) {
155+
return {
156+
ok: true,
157+
value: (result as { data: unknown }).data as Infer<S>,
158+
}
159+
}
160+
return {
161+
ok: false,
162+
errors: normalizeZodError((result as { error: unknown }).error),
163+
}
164+
}
165+
166+
throw new TypeError(
167+
'validateSchema: unsupported schema kind. Expected a Zod schema or ' +
168+
'an object with a safeParse method.',
169+
)
170+
}

0 commit comments

Comments
 (0)