diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9a1c0fb..f323841 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,7 +10,7 @@
-->
5.9.0
5.9.0
- 1.66.27
+ 1.68.4
1.0.0-prerelease.42
10.0.12
diff --git a/README.md b/README.md
index e04ca40..4c3e763 100644
--- a/README.md
+++ b/README.md
@@ -114,9 +114,13 @@ var urlSchema = Z.String().Url();
var urlResult = urlSchema.Validate("https://example.com");
// UUID validation
-var uuidSchema = Z.String().Uuid();
+var uuidSchema = Z.String().UUID();
var uuidResult = uuidSchema.Validate("550e8400-e29b-41d4-a716-446655440000");
+// Version-specific UUID validation (e.g. RFC 9562 version 7)
+var uuidV7Schema = Z.String().UUID(UuidVersion.V7);
+var uuidV7Result = uuidV7Schema.Validate("0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c");
+
// String transformations
var trimmedSchema = Z.String().Trim();
var upperSchema = Z.String().ToUpper();
diff --git a/docs/wiki/JsonSchema-Import.md b/docs/wiki/JsonSchema-Import.md
index 2ff526f..69e965c 100644
--- a/docs/wiki/JsonSchema-Import.md
+++ b/docs/wiki/JsonSchema-Import.md
@@ -39,7 +39,7 @@ var result = userSchema.Validate(userData);
- `type` — `string` / `number` / `integer` / `boolean` / `null` / `object` / `array`.
- `enum` → `ZodUnion` of literals (a single member becomes a literal); `const` → literal.
- `anyOf` / `oneOf` → `ZodUnion`; `allOf` → first schema.
-- String constraints — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`).
+- String constraints — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`). The `uuid`/`guid` format maps to the versionless `.UUID()`; JSON Schema has no versioned `uuid` format, so a versioned `.UUID(UuidVersion.V7)` schema exports back as plain `format: "uuid"`.
- Numeric constraints — `minimum`, `maximum`, `multipleOf`; `integer` additionally applies `.Int()`.
- Objects — `required` and optional fields via `Z.Object().Field(...)`.
- Arrays — `items`, `minItems`, `maxItems`.
diff --git a/docs/wiki/Performance.md b/docs/wiki/Performance.md
index 58a40c9..d37548d 100644
--- a/docs/wiki/Performance.md
+++ b/docs/wiki/Performance.md
@@ -98,6 +98,24 @@ All valid-input paths are zero-allocation:
| ValidateObject_Allocations | 94.28 ns | 2.06 |
| ValidateArray_Allocations | 1,180.24 ns | 25.82 |
+## UUID validation (`UuidPerformanceTests`)
+
+UUID validation uses a zero-allocation char-scan (version nibble at position 14, variant nibble at position 19) instead of a regex. Measured against the previous compiled regex:
+
+| Scenario | Mean | Allocated |
+|---|---|---|
+| Rule_CharScan_Valid (`.UUID()`) | 21.99 ns | 0 B |
+| Rule_LegacyRegex_Valid (previous implementation) | 27.50 ns | 0 B |
+| Rule_CharScan_Invalid | < 1 ns | 0 B |
+| Rule_LegacyRegex_Invalid | 14.22 ns | 0 B |
+| Rule_CharScan_Nil | 20.67 ns | 0 B |
+| Rule_CharScanV7_Valid (`.UUID(UuidVersion.V7)`) | 20.89 ns | 0 B |
+| Rule_CharScanV7_Mismatch | 20.11 ns | 0 B |
+| Schema_UUID_Valid | 29.80 ns | 0 B |
+| Schema_UUIDV7_Valid | 26.64 ns | 0 B |
+
+The char-scan is ~20% faster than the previous regex on the valid path, is version-aware at no extra cost, and rejects wrong-length strings in under a nanosecond.
+
## Optimizations that make it fast
1. **Struct-based rules** — every rule is a `readonly record struct` implementing `IValidationRule`, so there is no per-validation object allocation.
diff --git a/docs/wiki/String-Validation.md b/docs/wiki/String-Validation.md
index 9443e63..d597f7e 100644
--- a/docs/wiki/String-Validation.md
+++ b/docs/wiki/String-Validation.md
@@ -22,7 +22,8 @@ var result = schema.Validate("user@example.com");
| `Phone` | `Phone(string? message)` | `PhoneRule` — digits plus `() .+-`, at least one digit |
| `CreditCard` | `CreditCard(string? message)` | `CreditCardRule` — Luhn algorithm |
| `Base64String` | `Base64String(string? message)` | `Base64StringRule` — `Convert.FromBase64String` |
-| `UUID` | `UUID(string? message)` | `UUIDRule` — `xxxxxxxx-xxxx-...` regex |
+| `UUID` | `UUID(string? message)` | `UUIDRule` — char-scan, RFC 9562 versions 1-8, variant nibble `8-9/a-b`, plus nil and max |
+| `UUID` | `UUID(UuidVersion version, string? message)` | `UUIDRule` — requires a specific version (e.g. `V7`), variant nibble `8-9/a-b`, nil/max rejected |
| `StartsWith` | `StartsWith(string prefix, string? message)` | `StartsWithRule` — ordinal comparison |
| `EndsWith` | `EndsWith(string suffix, string? message)` | `EndsWithRule` — ordinal comparison |
| `ToLower` | `ToLower()` | wraps a transform (`ToLowerInvariant`), returns a `ZodString` |
@@ -42,6 +43,8 @@ var url = Z.String().Url().Validate("https://example.com");
var uuid = Z.String().UUID().Validate("550e8400-e29b-41d4-a716-446655440000");
+var uuidV7 = Z.String().UUID(UuidVersion.V7).Validate("0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c");
+
var prefix = Z.String().StartsWith("https://");
var suffix = Z.String().EndsWith(".com");
diff --git a/global.json b/global.json
index ec26fcf..e512622 100644
--- a/global.json
+++ b/global.json
@@ -3,9 +3,9 @@
"allowPrerelease": false
},
"msbuild-sdks": {
- "Purview.DotNetProjectSdk": "1.0.0-prerelease.54"
+ "Purview.DotNetProjectSdk": "1.0.0-prerelease.55"
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
-}
\ No newline at end of file
+}
diff --git a/package.json b/package.json
index 998724e..906dbc7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "zodsharp",
- "version": "2.0.0-prerelease.6",
+ "version": "2.0.0-prerelease.7",
"private": true,
"license": "MIT",
"author": {
@@ -28,4 +28,4 @@
"bun": ">=1.4.2"
},
"packageManager": "bun@1.4.2"
-}
\ No newline at end of file
+}
diff --git a/src/src/Benchmarks/UuidPerformanceTests.cs b/src/src/Benchmarks/UuidPerformanceTests.cs
new file mode 100644
index 0000000..3225e78
--- /dev/null
+++ b/src/src/Benchmarks/UuidPerformanceTests.cs
@@ -0,0 +1,67 @@
+using System.Text.RegularExpressions;
+using BenchmarkDotNet.Attributes;
+using ZodSharp.Core;
+using ZodSharp.Rules;
+using ZodSharp.Schemas;
+
+namespace ZodSharp;
+
+///
+/// Performance tests for UUID validation: char-scan rule vs the legacy compiled regex,
+/// plus the schema-level validation paths.
+///
+[MemoryDiagnoser]
+[SimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 5)]
+public class UuidPerformanceTests
+{
+ static readonly string ValidUuidV4 = "550e8400-e29b-41d4-a716-446655440000";
+ static readonly string ValidUuidV7 = "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c";
+ static readonly string InvalidUuid = "550e8400-e29b-41d4-a716";
+ static readonly string NilUuid = "00000000-0000-0000-0000-000000000000";
+
+ static readonly Regex LegacyUuidRegex = new(
+ @"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase,
+ TimeSpan.FromMilliseconds(100)
+ );
+
+ readonly UUIDRule _uuidRule;
+ readonly UUIDRule _uuidV7Rule;
+ readonly ZodString _uuidSchema;
+ readonly ZodString _uuidV7Schema;
+
+ public UuidPerformanceTests()
+ {
+ _uuidRule = new();
+ _uuidV7Rule = new(UuidVersion.V7);
+ _uuidSchema = Z.String().UUID();
+ _uuidV7Schema = Z.String().UUID(UuidVersion.V7);
+ }
+
+ [Benchmark]
+ public bool Rule_CharScan_Valid() => _uuidRule.IsValid(ValidUuidV4);
+
+ [Benchmark]
+ public bool Rule_LegacyRegex_Valid() => LegacyUuidRegex.IsMatch(ValidUuidV4);
+
+ [Benchmark]
+ public bool Rule_CharScan_Invalid() => _uuidRule.IsValid(InvalidUuid);
+
+ [Benchmark]
+ public bool Rule_LegacyRegex_Invalid() => LegacyUuidRegex.IsMatch(InvalidUuid);
+
+ [Benchmark]
+ public bool Rule_CharScan_Nil() => _uuidRule.IsValid(NilUuid);
+
+ [Benchmark]
+ public bool Rule_CharScanV7_Valid() => _uuidV7Rule.IsValid(ValidUuidV7);
+
+ [Benchmark]
+ public bool Rule_CharScanV7_Mismatch() => _uuidV7Rule.IsValid(ValidUuidV4);
+
+ [Benchmark]
+ public ValidationResult Schema_UUID_Valid() => _uuidSchema.Validate(ValidUuidV4);
+
+ [Benchmark]
+ public ValidationResult Schema_UUIDV7_Valid() => _uuidV7Schema.Validate(ValidUuidV7);
+}
diff --git a/src/src/Examples.CLI/AdvancedExamples.cs b/src/src/Examples.CLI/AdvancedExamples.cs
index f895394..4d11d16 100644
--- a/src/src/Examples.CLI/AdvancedExamples.cs
+++ b/src/src/Examples.CLI/AdvancedExamples.cs
@@ -36,6 +36,10 @@ static void StringAdvancedExamples()
var uuidResult = uuidSchema.Validate("550e8400-e29b-41d4-a716-446655440000");
Console.WriteLine($"UUID validation: {uuidResult.IsSuccess}");
+ var uuidV7Schema = Z.String().UUID(UuidVersion.V7);
+ var uuidV7Result = uuidV7Schema.Validate("0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c");
+ Console.WriteLine($"UUID v7 validation: {uuidV7Result.IsSuccess}");
+
var prefixSchema = Z.String().StartsWith("https://");
var prefixResult = prefixSchema.Validate("https://example.com");
Console.WriteLine($"StartsWith validation: {prefixResult.IsSuccess}");
diff --git a/src/src/ZodSharp/Rules/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs
index 734a1e0..892cdca 100644
--- a/src/src/ZodSharp/Rules/UuidRule.cs
+++ b/src/src/ZodSharp/Rules/UuidRule.cs
@@ -1,5 +1,3 @@
-using System.Text.RegularExpressions;
-
namespace ZodSharp.Rules;
///
@@ -8,20 +6,31 @@ namespace ZodSharp.Rules;
///
public readonly record struct UUIDRule : Core.IValidationRule
{
- static readonly Regex UUIDRegex = new(
- @"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
- RegexOptions.Compiled | RegexOptions.IgnoreCase,
- TimeSpan.FromMilliseconds(100)
- );
-
readonly string? _message;
+ readonly UuidVersion? _version;
///
- /// Initializes a new instance of the UuidRule struct.
+ /// Initializes a new instance of the UuidRule struct with Zod-parity semantics
+ /// (version 1-8, variant 8-9/a-b, plus the nil and max UUIDs).
///
/// Optional error message
public UUIDRule(string? message = null)
{
+ _version = null;
+ _message = message.OrNull();
+ }
+
+ ///
+ /// Initializes a new instance of the UuidRule struct that requires a specific RFC 9562 version.
+ ///
+ /// The required UUID version
+ /// Optional error message
+ public UUIDRule(UuidVersion version, string? message = null)
+ {
+ if (version == UuidVersion.None)
+ throw new ArgumentOutOfRangeException(nameof(version), version, "UUID version must be between V1 and V8.");
+
+ _version = version;
_message = message.OrNull();
}
@@ -30,12 +39,64 @@ public UUIDRule(string? message = null)
///
/// The value to validate
/// True if valid, false otherwise
- public bool IsValid(in string value) => !string.IsNullOrWhiteSpace(value) && UUIDRegex.IsMatch(value);
+ public bool IsValid(in string value)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.Length != 36)
+ return false;
+
+ if (!HasValidStructure(value))
+ return false;
+
+ if (_version is UuidVersion version)
+ return value[14] == (char)('0' + (int)version) && IsValidVariant(value[19]);
+
+ return IsValidVersionless(value);
+ }
///
/// Gets the error message for a failed validation.
///
/// The value that failed validation
/// The error message
- public string GetErrorMessage(in string value) => _message ?? $"Invalid UUID format: {value}";
+ public string GetErrorMessage(in string value) =>
+ _message
+ ?? (
+ _version is UuidVersion version
+ ? $"Invalid UUID v{(int)version} format: {value}"
+ : $"Invalid UUID format: {value}"
+ );
+
+ static bool IsValidVersionless(string value)
+ {
+ // nil and max are allowed regardless of version/variant (Zod parity).
+ if (value == "00000000-0000-0000-0000-000000000000")
+ return true;
+ if (value == "ffffffff-ffff-ffff-ffff-ffffffffffff")
+ return true;
+
+ var version = value[14];
+ if (version is < '1' or > '8')
+ return false;
+
+ return IsValidVariant(value[19]);
+ }
+
+ static bool HasValidStructure(string value)
+ {
+ if (value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-')
+ return false;
+
+ for (var i = 0; i < 36; i++)
+ {
+ if (i is 8 or 13 or 18 or 23)
+ continue;
+
+ if (!char.IsAsciiHexDigit(value[i]))
+ return false;
+ }
+
+ return true;
+ }
+
+ static bool IsValidVariant(char c) => c is '8' or '9' or 'a' or 'b' or 'A' or 'B';
}
diff --git a/src/src/ZodSharp/Schemas/ZodString.cs b/src/src/ZodSharp/Schemas/ZodString.cs
index 9b07e68..e65f628 100644
--- a/src/src/ZodSharp/Schemas/ZodString.cs
+++ b/src/src/ZodSharp/Schemas/ZodString.cs
@@ -158,7 +158,8 @@ public ZodString Base64String(string? message = null)
}
///
- /// Adds a UUID format validation.
+ /// Adds a UUID format validation. Accepts RFC 9562 versions 1-8 with the RFC
+ /// variant nibble, plus the nil and max UUIDs.
///
/// Optional error message
/// This schema for method chaining
@@ -168,6 +169,18 @@ public ZodString UUID(string? message = null)
return this;
}
+ ///
+ /// Adds a UUID format validation requiring a specific RFC 9562 version.
+ ///
+ /// The required UUID version
+ /// Optional error message
+ /// This schema for method chaining
+ public ZodString UUID(UuidVersion version, string? message = null)
+ {
+ AddRule(new UUIDRule(version, message));
+ return this;
+ }
+
///
/// Adds a validation that the string must start with the specified prefix.
///
diff --git a/src/src/ZodSharp/UuidVersion.cs b/src/src/ZodSharp/UuidVersion.cs
new file mode 100644
index 0000000..84fc62b
--- /dev/null
+++ b/src/src/ZodSharp/UuidVersion.cs
@@ -0,0 +1,34 @@
+namespace ZodSharp;
+
+///
+/// RFC 9562 UUID versions supported by .
+///
+public enum UuidVersion
+{
+ /// No version (only valid for the versionless UUID() overload).
+ None = 0,
+
+ /// Version 1 — time-based UUID.
+ V1 = 1,
+
+ /// Version 2 — DCE security UUID.
+ V2 = 2,
+
+ /// Version 3 — name-based (MD5) UUID.
+ V3 = 3,
+
+ /// Version 4 — random UUID.
+ V4 = 4,
+
+ /// Version 5 — name-based (SHA-1) UUID.
+ V5 = 5,
+
+ /// Version 6 — reordered time-based UUID.
+ V6 = 6,
+
+ /// Version 7 — Unix time-based UUID.
+ V7 = 7,
+
+ /// Version 8 — custom UUID.
+ V8 = 8,
+}
diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs
index db56bce..8909952 100644
--- a/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs
+++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs
@@ -74,6 +74,18 @@ public async Task StringUrl_GivenValue_ReturnsExpectedResult(string value, bool
[Test]
[Arguments("550e8400-e29b-41d4-a716-446655440000", true)]
+ [Arguments("550e8400-e29b-11d4-a716-446655440000", true)]
+ [Arguments("0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c", true)]
+ [Arguments("550E8400-E29B-41D4-A716-446655440000", true)]
+ [Arguments("00000000-0000-0000-0000-000000000000", true)]
+ [Arguments("ffffffff-ffff-ffff-ffff-ffffffffffff", true)]
+ [Arguments("550e8400-e29b-01d4-a716-446655440000", false)]
+ [Arguments("550e8400-e29b-91d4-a716-446655440000", false)]
+ [Arguments("550e8400-e29b-f1d4-a716-446655440000", false)]
+ [Arguments("550e8400-e29b-41d4-0716-446655440000", false)]
+ [Arguments("550e8400-e29b-41d4-5716-446655440000", false)]
+ [Arguments("550e8400-e29b-41d4-c716-446655440000", false)]
+ [Arguments("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", false)]
[Arguments("not-a-uuid", false)]
[Arguments("550e8400-e29b-41d4-a716", false)]
public async Task StringUuid_GivenValue_ReturnsExpectedResult(string value, bool expected)
@@ -83,6 +95,21 @@ public async Task StringUuid_GivenValue_ReturnsExpectedResult(string value, bool
await Assert.That(result.IsSuccess).IsEqualTo(expected);
}
+ [Test]
+ [Arguments(UuidVersion.V7, "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c", true)]
+ [Arguments(UuidVersion.V7, "550e8400-e29b-41d4-a716-446655440000", false)]
+ [Arguments(UuidVersion.V7, "0192b4c1-7a9b-7f5e-03a3-2d4e6f8a0b1c", false)]
+ [Arguments(UuidVersion.V4, "550e8400-e29b-41d4-a716-446655440000", true)]
+ [Arguments(UuidVersion.V4, "00000000-0000-0000-0000-000000000000", false)]
+ [Arguments(UuidVersion.V4, "ffffffff-ffff-ffff-ffff-ffffffffffff", false)]
+ [Arguments(UuidVersion.V8, "550e8400-e29b-81d4-a716-446655440000", true)]
+ public async Task StringUuid_GivenVersion_ReturnsExpectedResult(UuidVersion version, string value, bool expected)
+ {
+ var result = Z.String().UUID(version).Validate(value);
+
+ await Assert.That(result.IsSuccess).IsEqualTo(expected);
+ }
+
[Test]
public async Task StringStartsWith_GivenMatchingPrefix_ReturnsSuccess()
{