UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

381 lines (351 loc) 14.9 kB
import { z as zod } from "zod"; import enLocale from "zod/v4/locales/en.js"; import "./zodAugment.ts"; // Re-activate zod's English error map. zod's classic entry does this as a module // side effect (`config(en())`) so messages read "Too small: expected string to // have >=2 characters" instead of a bare "Invalid input". That side effect is // tree-shaken away under bundlers (zod is `sideEffects: false`) once nothing // pins the whole zod namespace, so we call it explicitly from alepha (which is // not `sideEffects: false`, so the call survives). Bundles only `en` (~1 KB gz). zod.config(enLocale()); /** * Alepha's `z` — a thin, enhanceable wrapper over zod 4 that mirrors the * ergonomics of the legacy typebox `t` provider (same call-shapes, so existing * `t.*` call-sites become a near-mechanical rename), while delegating all * validation + static-type inference to zod. * * Design notes: * - No encode/decode. string -> string, number -> number. Validation is a plain * `schema.parse(value)` — zod does NOT use `Function`/`eval`, so this is safe * inside Cloudflare Workers (unlike typebox's `Compile`). * - Metadata (`title`, `description`, `format`, custom `~options`) rides on zod's * native `.meta()` registry, so `z.toJSONSchema()` round-trips it for OpenAPI * + form generation. Defaults use `.default()`. */ // --------------------------------------------------------------------------- // Re-exported type aliases (the spine swap: Static->Infer, TObject->ZObject…) // --------------------------------------------------------------------------- export type ZType = zod.ZodType; export type ZObject<T extends zod.ZodRawShape = any> = zod.ZodObject<T>; /** Replaces typebox `Static<T>`. */ export type Infer<T extends zod.ZodType> = zod.infer<T>; /** * Shallow output-extraction for `T extends SomeSchema ? Static<T> : Fallback` * conditional positions. * * Reads zod's `_zod.output` directly (a single shallow conditional) instead of * first testing `T extends TRequestBody` / `TResponseBody` — those tests compare * `T` against 5-6 member unions of deeply-recursive zod class types, and that * structural assignability check is what trips TypeScript's instantiation-depth * limit (TS2589) inside `$action`'s generics. The extracted type is identical to * `Static<T>` for any real schema; non-schema `T` (e.g. `undefined`) yields * `Fallback`. */ export type SchemaOutput<T, Fallback = any> = [T] extends [ { _zod: { output: infer O } }, ] ? O : Fallback; export interface SchemaOptions { title?: string; description?: string; default?: unknown; format?: string; /** Alepha string transforms, kept for JSON-Schema round-trip parity. */ "~options"?: { trim?: boolean; lowercase?: boolean }; [key: string]: unknown; } export interface StringOptions extends SchemaOptions { minLength?: number; maxLength?: number; pattern?: string | RegExp; } export interface NumberOptions extends SchemaOptions { minimum?: number; maximum?: number; } export interface TextOptions extends StringOptions { size?: "short" | "regular" | "long" | "rich"; trim?: boolean; lowercase?: boolean; } // --------------------------------------------------------------------------- /** Defaults mirroring TypeProvider's static length caps. */ export const Z_LIMITS = { short: 64, regular: 255, long: 1024, rich: 65535, arrayMaxItems: 1000, }; /** Attach metadata + default without changing the schema's inferred type. */ const meta = <T extends zod.ZodType>(schema: T, options?: SchemaOptions): T => { if (!options) return schema; const { default: def, ...rest } = options; let s: zod.ZodType = schema; if (def !== undefined) s = s.default(def as never); if (Object.keys(rest).length > 0) s = s.meta(rest as Record<string, unknown>); return s as T; }; const applyString = (base: zod.ZodString, o?: StringOptions): zod.ZodString => { let s = base; if (o?.minLength != null) s = s.min(o.minLength); if (o?.maxLength != null) s = s.max(o.maxLength); if (o?.pattern) s = s.regex( typeof o.pattern === "string" ? new RegExp(o.pattern) : o.pattern, ); return s; }; /** zod's native `.format` getter (uuid/email/safeint/…), guarded. */ const nativeFmt = (s: any): string | undefined => { try { return (s?.format ?? undefined) as string | undefined; } catch { return undefined; } }; /** * Read the JSON-Schema-style format tag off a schema (drives ORM columns). * Prefers our normalized `.meta({ format })` (typebox vocab: `date-time`, * `int64`, `binary`, …) and falls back to zod's native `.format`. */ const fmt = (s: any): string | undefined => { try { return (s?.meta?.()?.format ?? nativeFmt(s)) as string | undefined; } catch { return undefined; } }; /** zod's internal type discriminator (`string` | `number` | …), wrapping-safe. */ const defType = (s: any): string | undefined => s?._zod?.def?.type; /** Tag a string-format schema with its JSON-Schema `format` (stays a real `ZodString`). */ const strFmt = (base: zod.ZodType, format: string): zod.ZodString => meta(base, { format }) as unknown as zod.ZodString; // --------------------------------------------------------------------------- // The `z` provider — mirrors `t`'s surface. // --------------------------------------------------------------------------- export const z = { /** * Native zod coercion namespace (`z.coerce.number()`, `z.coerce.boolean()`…). * Used at the HTTP/string boundary (query, headers, env) where everything * arrives as a string; request bodies and the ORM stay strict (no coercion). */ coerce: zod.coerce, // -- native builders (direct zod references — `z.object` IS `zod.object`) - // No alepha wrapper: schemas are configured by chaining native zod methods // (`.min()`, `.max()`, `.describe()`, `.meta()`, `.default()`, `.optional()`). object: zod.object, array: zod.array, record: zod.record, tuple: zod.tuple, union: zod.union, string: zod.string, number: zod.number, boolean: zod.boolean, null: zod.null, any: zod.any, void: zod.void, undefined: zod.undefined, literal: zod.literal, /** Alias for `zod.literal` (legacy `t.const`). */ const: zod.literal, enum: zod.enum, /** Integer — native `z.int()` (format `safeint`, drives PG INT column). */ integer: zod.int, /** Free-form JSON object (`Record<string, any>`). */ json: () => zod.record(zod.string(), zod.any()), // -- text family (alepha size-capped strings — the options ARE the API) -- text: (options: TextOptions = {}) => { const { size = "regular", trim = true, lowercase = false, maxLength, ...rest } = options; // An explicit `maxLength` overrides the size-based default cap (otherwise // both `.max()` calls stack and zod keeps the smaller — silently capping // a `maxLength: 1_000_000` field at 255). const max = maxLength ?? Z_LIMITS[size] ?? Z_LIMITS.regular; let s = zod.string().max(max); if (trim) s = s.trim(); if (lowercase) s = s.toLowerCase(); return meta(applyString(s, rest), { ...rest, maxLength: max, "~options": { trim, lowercase }, }); }, shortText: (options: TextOptions = {}) => z.text({ ...options, size: "short" }), longText: (options: TextOptions = {}) => z.text({ ...options, size: "long" }), richText: (options: TextOptions = {}) => z.text({ ...options, size: "rich" }), // -- string formats (format tag drives ORM column-type detection) -------- // All return a real `ZodString` (assignable to `TString`, instanceof-safe); // the JSON-Schema format rides on `.meta()` and is read via `z.schema.format`. email: () => strFmt(zod.email(), "email"), // `z.guid` accepts any 8-4-4-4-12 hex UUID; `z.uuid` additionally enforces an // RFC version nibble (1-8). A generic UUID column stores arbitrary UUIDs (only // generated PKs are v7), so the lenient GUID validator is the correct choice. uuid: () => strFmt(zod.guid(), "uuid"), url: () => strFmt(zod.url(), "url"), datetime: () => strFmt(zod.iso.datetime(), "date-time"), date: () => strFmt(zod.iso.date(), "date"), time: () => strFmt(zod.iso.time(), "time"), /** bigint as a validated string (no codec). */ bigint: () => strFmt(zod.string().regex(/^-?\d+$/), "bigint"), binary: () => strFmt(zod.string(), "binary"), duration: () => strFmt(zod.string(), "duration"), e164: () => z.text({ pattern: "^\\+[1-9]\\d{1,14}$" }), bcp47: () => z.text({ pattern: "^[a-z]{2,3}(?:-[A-Z]{2})?$" }), constantCase: () => z.text({ pattern: "^[A-Z_-]+$" }), // -- numeric variants ---------------------------------------------------- int32: () => zod.int().min(-2147483648).max(2147483647), int64: () => zod .int() .min(-9007199254740991) .max(9007199254740991) .meta({ format: "int64" }), // -- file-like / composite ----------------------------------------------- file: (options?: { maxSize?: number }) => zod.any().meta({ format: "binary", ...options }), stream: () => zod.any().meta({ format: "stream" }), valueLabel: () => zod.object({ value: z.constantCase(), label: z.text(), description: z.longText().optional(), }), /** Pagination wrapper, mirrors `pageSchema(item)`. */ page: <T extends zod.ZodType>(item: T) => zod.object({ content: zod.array(item), page: zod.object({ number: zod.int(), size: zod.int(), offset: zod.int(), numberOfElements: zod.int(), totalElements: zod.int().optional(), totalPages: zod.int().optional(), isEmpty: zod.boolean(), isFirst: zod.boolean(), isLast: zod.boolean(), }), }), // NOTE: modifiers (optional / nullable), structural transforms // (pick / omit / partial / extend) and validation (parse / safeParse) are // NOT provided here — call the native zod methods directly on the schema // (`schema.optional()`, `schema.pick({ a: true })`, `schema.parse(v)`, …). /** Export a schema to JSON Schema (OpenAPI / form generation). */ toJSONSchema: zod.toJSONSchema, /** Runtime type guards (`z.schema.*`) — schema introspection for ORM + forms. */ schema: { isOptional: (s: any) => s instanceof zod.ZodOptional, isDefault: (s: any) => s instanceof zod.ZodDefault, isObject: (s: any) => s instanceof zod.ZodObject, isString: (s: any) => defType(s) === "string", isNumber: (s: any) => defType(s) === "number", isBoolean: (s: any) => s instanceof zod.ZodBoolean, isArray: (s: any) => s instanceof zod.ZodArray, isUnion: (s: any) => s instanceof zod.ZodUnion, isNull: (s: any) => s instanceof zod.ZodNull, isRecord: (s: any) => s instanceof zod.ZodRecord, // Explicit `: boolean` (not an inferred type-predicate): the `s is ZodEnum` // / `s is ZodLiteral` predicates leak zod's non-exported `EnumValue` / // `Literal` internals into consumers' generated `.d.ts` (TS2883). No // call-site relies on narrowing these — they read via `enumValues`/`.value`. isEnum: (s: any): boolean => s instanceof zod.ZodEnum, isLiteral: (s: any): boolean => s instanceof zod.ZodLiteral, isAny: (s: any) => s instanceof zod.ZodAny, isNullable: (s: any) => s instanceof zod.ZodNullable, isInteger: (s: any) => defType(s) === "number" && nativeFmt(s) === "safeint", isText: (s: any) => defType(s) === "string" && !fmt(s), isDateTime: (s: any) => fmt(s) === "date-time", isDate: (s: any) => fmt(s) === "date", isTime: (s: any) => fmt(s) === "time", isBigInt: (s: any) => fmt(s) === "bigint", isUuid: (s: any) => fmt(s) === "uuid", isEmail: (s: any) => fmt(s) === "email", isUrl: (s: any) => fmt(s) === "url", isBinary: (s: any) => fmt(s) === "binary", isUUID: (s: any) => fmt(s) === "uuid", // Explicit `: boolean` — ORs `ZodLiteral`/`ZodEnum` whose inferred predicate // would leak zod's non-exported `Literal`/`EnumValue` internals (TS2883). isScalar: (s: any): boolean => s instanceof zod.ZodString || s instanceof zod.ZodNumber || s instanceof zod.ZodBoolean || s instanceof zod.ZodNull || s instanceof zod.ZodLiteral || s instanceof zod.ZodEnum || s instanceof zod.ZodDate, isSchema: (s: any) => s instanceof zod.ZodType, isTuple: (s: any) => s instanceof zod.ZodTuple, isUndefined: (s: any) => s instanceof zod.ZodUndefined, isUnsafe: (s: any) => s instanceof zod.ZodAny || s instanceof zod.ZodUnknown, isVoid: (s: any) => s instanceof zod.ZodVoid, format: (s: any) => fmt(s), /** The string values of a `ZodEnum` (typebox-compat for `value.enum`). */ enumValues: (s: any): string[] => { const opts = s?.options; if (Array.isArray(opts)) return opts as string[]; const entries = s?._zod?.def?.entries; return entries ? (Object.values(entries) as string[]) : []; }, /** Peel optional / nullable / default wrappers to the inner schema. */ unwrap: (s: any): any => { let cur = s; while ( cur instanceof zod.ZodOptional || cur instanceof zod.ZodNullable || cur instanceof zod.ZodDefault ) { cur = typeof cur.unwrap === "function" ? cur.unwrap() : cur?._zod?.def?.innerType; } return cur; }, /** * Read a schema's default value (peeling optional/nullable wrappers), or * `undefined` if there is none. In zod, defaults are a `ZodDefault` wrapper * (not a `default` data property as in typebox), so `"default" in schema` * is NOT a valid test — every schema has a `.default()` method. */ getDefault: (s: any): unknown => { // An explicit own `default` property (attached by the ORM's `default()` // helper via Object.assign) takes precedence over a zod ZodDefault // wrapper. Guarded with hasOwn so the prototype `.default()` *method* is // never mistaken for a default value. if (s && Object.hasOwn(s, "default")) return s.default; let cur = s; while (cur) { if (cur instanceof zod.ZodDefault) { const dv = cur?._zod?.def?.defaultValue; return typeof dv === "function" ? dv() : dv; } if (cur instanceof zod.ZodOptional || cur instanceof zod.ZodNullable) { cur = cur.unwrap(); continue; } return undefined; } return undefined; }, /** typebox-compat: the object's required (non-optional) field names. */ requiredKeys: (s: any): string[] => { const shape = s?.shape; return shape ? Object.keys(shape).filter( (k) => !(shape[k] instanceof zod.ZodOptional), ) : []; }, }, }; export type Z = typeof z;