alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
381 lines (351 loc) • 14.9 kB
text/typescript
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;