Skip to content

Commit

Permalink
get template literal tests working
Browse files Browse the repository at this point in the history
  • Loading branch information
colinhacks committed Mar 1, 2025
1 parent 8df594e commit 67a8e1d
Show file tree
Hide file tree
Showing 11 changed files with 491 additions and 151 deletions.
3 changes: 2 additions & 1 deletion packages/zod-core/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export interface $ZodType<out O = unknown, out I = unknown> {
: [$ZodRegistry<R["_meta"], this>["_meta"]]
: ["Incompatible schema"]
): this;
$brand<T extends PropertyKey = PropertyKey>(): this & Record<"_output", this["_output"] & $brand<T>>;
$brand<T extends PropertyKey = PropertyKey>(brand?: T): this & Record<"_output", this["_output"] & $brand<T>>;
// {
// _output: O & $brand<T>;
// };
Expand Down Expand Up @@ -254,6 +254,7 @@ export interface $ZodType<out O = unknown, out I = unknown> {
* Todo: unions?
*/
_values?: $PrimitiveSet | undefined;
_pattern?: RegExp;
/** @deprecated Internal API, use with caution. */
_def: $ZodTypeDef;
/** @deprecated Internal API, use with caution.
Expand Down
33 changes: 28 additions & 5 deletions packages/zod-core/src/checks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { min } from "./api.js";
import * as base from "./base.js";
import type * as errors from "./errors.js";
import * as regexes from "./regexes.js";
Expand Down Expand Up @@ -38,6 +37,7 @@ export const $ZodCheckLessThan: base.$constructor<$ZodCheckLessThan> = base.$con

inst._onattach = (inst) => {
const curr = inst._computed.maximum ?? Number.POSITIVE_INFINITY;

if (def.value < curr) inst._computed.maximum = def.value;
};

Expand Down Expand Up @@ -222,6 +222,9 @@ export const $ZodCheckNumberFormat: base.$constructor<$ZodCheckNumberFormat> = /
inst._computed.format = def.format;
inst._computed.minimum = minimum;
inst._computed.maximum = maximum;
if (isInt) {
inst._computed.pattern = regexes.intRegex;
}
};

inst._check = (payload) => {
Expand Down Expand Up @@ -404,8 +407,8 @@ export const $ZodCheckMaxSize: base.$constructor<$ZodCheckMaxSize> = base.$const
};

inst._onattach = (inst) => {
const curr = (inst._computed.maximum ?? Number.NEGATIVE_INFINITY) as number;
if (def.maximum > curr) inst._computed.maximum = def.maximum;
const curr = (inst._computed.maximum ?? Number.POSITIVE_INFINITY) as number;
if (def.maximum < curr) inst._computed.maximum = def.maximum;
};

inst._check = (payload) => {
Expand Down Expand Up @@ -537,8 +540,8 @@ export const $ZodCheckMaxLength: base.$constructor<$ZodCheckMaxLength> = base.$c
};

inst._onattach = (inst) => {
const curr = (inst._computed.maximum ?? Number.NEGATIVE_INFINITY) as number;
if (def.maximum > curr) inst._computed.maximum = def.maximum;
const curr = (inst._computed.maximum ?? Number.POSITIVE_INFINITY) as number;
if (def.maximum < curr) inst._computed.maximum = def.maximum;
};

inst._check = (payload) => {
Expand Down Expand Up @@ -706,6 +709,19 @@ export interface $ZodCheckRegex extends $ZodCheckStringFormat {

export const $ZodCheckRegex: base.$constructor<$ZodCheckRegex> = base.$constructor("$ZodCheckRegex", (inst, def) => {
$ZodCheckStringFormat.init(inst, def);

inst._check = (payload) => {
if (def.pattern.test(payload.value)) return;
payload.issues.push({
origin: "string",
code: "invalid_format",
format: "regex",
input: payload.value,
pattern: def.pattern.toString(),
inst,
continue: !def.abort,
});
};
});

///////////////////////////////////
Expand Down Expand Up @@ -834,6 +850,9 @@ export const $ZodCheckStartsWith: base.$constructor<$ZodCheckStartsWith> = base.
"$ZodCheckStartsWith",
(inst, def) => {
base.$ZodCheck.init(inst, def);
inst._onattach = (inst) => {
inst._computed.pattern = new RegExp(`^${util.escapeRegex(def.prefix)}.*`);
};

inst._check = (payload) => {
if (payload.value.startsWith(def.prefix)) return;
Expand Down Expand Up @@ -867,6 +886,10 @@ export const $ZodCheckEndsWith: base.$constructor<$ZodCheckEndsWith> = base.$con
(inst, def) => {
base.$ZodCheck.init(inst, def);

inst._onattach = (inst) => {
inst._computed.pattern = new RegExp(`.*${util.escapeRegex(def.suffix)}$`);
};

inst._check = (payload) => {
if (payload.value.endsWith(def.suffix)) return;
payload.issues.push({
Expand Down
33 changes: 21 additions & 12 deletions packages/zod-core/src/regexes.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,33 @@
export const cuidRegex: RegExp = /^c[^\s-]{8,}$/i;
export const cuidRegex: RegExp = /^[cC][^\s-]{8,}$/;
export const cuid2Regex: RegExp = /^[0-9a-z]+$/;
export const ulidRegex: RegExp = /^[0-9A-HJKMNP-TV-Z]{26}$/;
export const xidRegex: RegExp = /^[0-9a-v]{20}$/i;
export const xidRegex: RegExp = /^[0-9a-vA-V]{20}$/;
export const ksuidRegex: RegExp = /^[A-Za-z0-9]{27}$/;
export const nanoidRegex: RegExp = /^[a-z0-9_-]{21}$/i;
export const nanoidRegex: RegExp = /^[a-zA-Z0-9_-]{21}$/;
export const durationRegex: RegExp =
/^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/;

/** A regex for any UUID-like identifier. */
export const guidRegex: RegExp = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
export const guidRegex: RegExp = /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/;

/** Returns a regex for validating an RFC 4122 UUID.
*
* @param version Optionally specify a version 1-8. If no version is specified, all versions are supported. */
export const uuidRegex = (version?: number | undefined): RegExp => {
if (!version)
return /^([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i;
return new RegExp(`^([0-9a-f]{8}-[0-9a-f]{4}-${version}[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, "i");
// return /^([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i;
return /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$/;
// return new RegExp(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${version}[0-9a-fA-F]{3}-[89ab][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`, "i");
return new RegExp(
`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${version}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`
);
};
export const uuid4Regex: RegExp = uuidRegex(4);
export const uuid6Regex: RegExp = uuidRegex(6);
export const uuid7Regex: RegExp = uuidRegex(7);

export const emailRegex: RegExp = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i;
export const emailRegex: RegExp =
/^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/;

// from https://thekevinscott.com/emojis-in-javascript/#writing-a-regular-expression
export const _emojiRegex = `^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$`;
Expand All @@ -33,7 +38,7 @@ export function emojiRegex(): RegExp {
export const ipv4Regex: RegExp =
/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/;
export const ipv6Regex: RegExp =
/^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/;
/^(([a-fA-F0-9]{1,4}:){7}|::([a-fA-F0-9]{1,4}:){0,6}|([a-fA-F0-9]{1,4}:){1}:([a-fA-F0-9]{1,4}:){0,5}|([a-fA-F0-9]{1,4}:){2}:([a-fA-F0-9]{1,4}:){0,4}|([a-fA-F0-9]{1,4}:){3}:([a-fA-F0-9]{1,4}:){0,3}|([a-fA-F0-9]{1,4}:){4}:([a-fA-F0-9]{1,4}:){0,2}|([a-fA-F0-9]{1,4}:){5}:([a-fA-F0-9]{1,4}:){0,1})([a-fA-F0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/;
export const ipRegex: RegExp = new RegExp(`(${ipv4Regex.source})|(${ipv6Regex.source})`);

// https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript
Expand Down Expand Up @@ -83,10 +88,14 @@ export function datetimeRegex(args: {
return new RegExp(`^${regex}$`);
}

export const stringRegex: RegExp = /^[\s\S]*$/;
export const bigintRegex: RegExp = /\\-?\\d+/;
export const intRegex: RegExp = /\\-?\\d+/;
export const numberRegex: RegExp = /-?\d+(?:\.\d+)?(?:e-?\d+)?/i;
export const stringRegex = (params?: { minimum?: number; maximum?: number }): RegExp => {
const regex = params ? `[\\s\\S]{${params?.minimum ?? 0},${params?.maximum ?? ""}}` : `[\\s\\S]*`;
return new RegExp(`^${regex}$`);
};

export const bigintRegex: RegExp = /^\d+n?$/;
export const intRegex: RegExp = /^\d+$/;
export const numberRegex: RegExp = /^-?\d+(?:\.\d+)?/i;
export const booleanRegex: RegExp = /true|false/i;
export const nullRegex: RegExp = /null/i;
export const undefinedRegex: RegExp = /undefined/i;
Expand Down
44 changes: 30 additions & 14 deletions packages/zod-core/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export interface $ZodString<Input = unknown> extends base.$ZodType<string, Input

export const $ZodString: base.$constructor<$ZodString> = /*@__PURE__*/ base.$constructor("$ZodString", (inst, def) => {
base.$ZodType.init(inst, def);
inst._pattern = regexes.stringRegex;
inst._pattern = inst?._computed?.pattern ?? regexes.stringRegex(inst._computed);

inst._parse = (payload, _) => {
if (def.coerce)
Expand Down Expand Up @@ -451,7 +451,7 @@ export interface $ZodNumber<T = unknown> extends base.$ZodType<number, T> {

export const $ZodNumber: base.$constructor<$ZodNumber> = /*@__PURE__*/ base.$constructor("$ZodNumber", (inst, def) => {
base.$ZodType.init(inst, def);
inst._pattern = regexes.numberRegex;
inst._pattern = inst._computed.pattern ?? regexes.numberRegex;

inst._parse = (payload, _ctx) => {
if (def.coerce)
Expand Down Expand Up @@ -516,12 +516,7 @@ export const $ZodNumberFormat: base.$constructor<$ZodNumberFormat> = /*@__PURE__
"$ZodNumber",
(inst, def) => {
checks.$ZodCheckNumberFormat.init(inst, def);
$ZodNumber.init(inst, def); // no format checks

// if format is integer:
if (def.format.includes("int")) {
inst._pattern = regexes.intRegex;
}
$ZodNumber.init(inst, def); // no format checksp
}
);

Expand Down Expand Up @@ -625,7 +620,7 @@ export interface $ZodBigIntFormat extends $ZodBigInt<bigint>, checks.$ZodCheckBi
}

export const $ZodBigIntFormat: base.$constructor<$ZodBigIntFormat> = /*@__PURE__*/ base.$constructor(
"$ZodNumber",
"$ZodBigInt",
(inst, def) => {
checks.$ZodCheckBigIntFormat.init(inst, def);
$ZodBigInt.init(inst, def); // no format checks
Expand Down Expand Up @@ -1427,6 +1422,8 @@ export interface $ZodUnion<T extends readonly base.$ZodType[] = readonly base.$Z
extends base.$ZodType<T[number]["_output"], T[number]["_input"]> {
_def: $ZodUnionDef;
_isst: errors.$ZodIssueInvalidUnion;

_pattern: [T[number]] extends { _pattern: RegExp } ? RegExp : never;
}

function handleUnionResults(
Expand Down Expand Up @@ -1463,6 +1460,13 @@ export const $ZodUnion: base.$constructor<$ZodUnion> = /*@__PURE__*/ base.$const
}
}
}

// computed union regex for _pattern if all options have _pattern
if (def.options.every((o) => (o as any)._pattern)) {
const patterns = def.options.map((o) => (o as any)._pattern);
(inst as any)._pattern = new RegExp(`^(${patterns.map((p) => util.cleanRegex(p.source)).join("|")})$`);
}

inst._values = values;

inst._parse = (payload, ctx) => {
Expand Down Expand Up @@ -2298,7 +2302,7 @@ export const $ZodLiteral: base.$constructor<$ZodLiteral> = /*@__PURE__*/ base.$c
inst._values = new Set<util.Primitive>(def.values);
inst._pattern = new RegExp(
`^(${def.values
.filter((k) => util.propertyKeyTypes.has(typeof k))
// .filter((k) => util.propertyKeyTypes.has(typeof k))
.map((o) => (typeof o === "string" ? util.escapeRegex(o) : o ? o.toString() : String(o)))
.join("|")})$`
);
Expand Down Expand Up @@ -2453,6 +2457,7 @@ export interface $ZodOptional<T extends base.$ZodType = base.$ZodType>
_qout: "true";
_isst: never;
_values: T["_values"];
_pattern: RegExp;
}

export const $ZodOptional: base.$constructor<$ZodOptional> = /*@__PURE__*/ base.$constructor(
Expand All @@ -2462,6 +2467,7 @@ export const $ZodOptional: base.$constructor<$ZodOptional> = /*@__PURE__*/ base.
// inst._qin = "true";
inst._qout = "true";
if (def.innerType._values) inst._values = new Set([...def.innerType._values, undefined]);
if (def.innerType._pattern) inst._pattern = new RegExp(`^(${util.cleanRegex(def.innerType._pattern.source)})?$`);

inst._parse = (payload, ctx) => {
if (payload.value === undefined) {
Expand Down Expand Up @@ -2895,13 +2901,14 @@ export interface $SchemaPart extends base.$ZodType<$LiteralPart, $LiteralPart> {
}
export type $TemplateLiteralPart = $LiteralPart | $SchemaPart;

type UndefinedToEmptyString<T> = T extends undefined ? "" : T;
type AppendToTemplateLiteral<
Template extends string,
Suffix extends $LiteralPart | base.$ZodType,
> = Suffix extends $LiteralPart
? `${Template}${Suffix}`
? `${Template}${UndefinedToEmptyString<Suffix>}`
: Suffix extends base.$ZodType<infer Output extends $LiteralPart>
? `${Template}${Output}`
? `${Template}${UndefinedToEmptyString<Output>}`
: never;

export type $PartsToTemplateLiteral<Parts extends $TemplateLiteralPart[]> = [] extends Parts
Expand All @@ -2917,14 +2924,21 @@ export const $ZodTemplateLiteral: base.$constructor<$ZodTemplateLiteral> = /*@__
const regexParts: string[] = [];
for (const part of def.parts) {
if (part instanceof base.$ZodType) {
if (!("_pattern" in part)) {
// if (!source)
throw new Error(`Invalid template literal part, no _pattern found: ${[...(part as any)._traits].shift()}`);
}
const source = part._pattern instanceof RegExp ? part._pattern.source : part._pattern;
if (!source) throw new Error(`Invalid template literal part: ${part._traits}`);

// if (!source) throw new Error(`Invalid template literal part: ${part._traits}`);

const start = source.startsWith("^") ? 1 : 0;
const end = source.endsWith("$") ? source.length - 1 : source.length;
regexParts.push(source.slice(start, end));
} else if (part === null || util.primitiveTypes.has(typeof part)) {
regexParts.push(util.escapeRegex(`${part}`));
} else {
regexParts.push(`${part}`);
throw new Error(`Invalid template literal part: ${part}`);
}
}
inst._pattern = new RegExp(`^${regexParts.join("")}$`);
Expand All @@ -2940,6 +2954,8 @@ export const $ZodTemplateLiteral: base.$constructor<$ZodTemplateLiteral> = /*@__
return payload;
}

inst._pattern.lastIndex = 0;

if (!inst._pattern.test(payload.value)) {
payload.issues.push({
input: payload.value,
Expand Down
16 changes: 7 additions & 9 deletions packages/zod-core/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,15 +487,7 @@ export const getParsedType = (data: any): ParsedTypes => {

export const propertyKeyTypes: Set<string> = new Set(["string", "number", "symbol"]);

export const primitiveTypes: Set<string> = new Set([
"string",
"number",
"bigint",
"boolean",
"symbol",
"undefined",
// "null",
]);
export const primitiveTypes: Set<string> = new Set(["string", "number", "bigint", "boolean", "symbol", "undefined"]);
export function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
Expand Down Expand Up @@ -1294,3 +1286,9 @@ export function issue(...args: [string | errors.$ZodRawIssue, any?, any?]): erro
export function nullish(input: any): boolean {
return input === null || input === undefined;
}

export function cleanRegex(source: string): string {
const start = source.startsWith("^") ? 1 : 0;
const end = source.endsWith("$") ? source.length - 1 : source.length;
return source.slice(start, end);
}
1 change: 1 addition & 0 deletions packages/zod/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1664,6 +1664,7 @@ export interface ZodOptional<T extends core.$ZodType = core.$ZodType>
_qout: "true";
_isst: never;
_values: T["_values"];
// _pattern: T["_pattern"];

unwrap(): T;
}
Expand Down
1 change: 1 addition & 0 deletions packages/zod/tests/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,7 @@ test("min max getters", () => {

expect(z.string().max(5).maxLength).toEqual(5);
expect(z.string().max(5).max(1).maxLength).toEqual(1);
expect(z.string().max(5).max(10).maxLength).toEqual(5);
expect(z.string().maxLength).toEqual(null);
});

Expand Down
Loading

0 comments on commit 67a8e1d

Please sign in to comment.