Schema-first validation library for .NET inspired by Zod. Build immutable, composable validation schemas with a fluent API. Parse data with detailed error messages and type-safe results.
Define schemas using the Sod static factory, then parse data to get a strongly-typed result with success/failure and error messages.
using Noundry.Sod;
// Define a schema
var emailSchema = Sod.String().Email().Max(255).ToLowerCase();
// Parse and validate
var result = emailSchema.Parse("John@Example.COM");
if (result.Success)
{
// result.Data = "john@example.com" (validated + transformed)
Console.WriteLine(result.Data);
}
else
{
// result.Errors contains detailed validation messages
foreach (var error in result.Errors)
Console.WriteLine(error);
}
All schemas follow an immutable, fluent builder pattern. Define once, reuse everywhere.
SodResult<T> returns Success, Data, and Errors -- no exceptions in normal flow.
Use Sod.Coerce.* to automatically convert string inputs from HTTP/JSON to typed values.
Nest schemas inside objects, arrays, unions, and transforms for complex data structures.
| Method | Returns | Throws | Use Case |
|---|---|---|---|
| Parse(input) | SodResult<T> | No | Standard parsing |
| SafeParse(input) | SodResult<T> | No | Catches all exceptions |
| ParseOrThrow(input) | T | SodValidationException | Exception-based flow |
Validates and transforms string values with length, format, and content constraints.
// Length constraints
Sod.String().Min(3).Max(100)
Sod.String().Length(8) // Exact length
// Format validators
Sod.String().Email() // Valid email
Sod.String().Url() // Valid HTTP/HTTPS URL
Sod.String().Uuid() // Valid UUID/GUID
Sod.String().Ip() // Valid IP address
Sod.String().Regex(@"^\d{3}-\d{4}$") // Custom regex
// Content validators
Sod.String().StartsWith("https")
Sod.String().EndsWith(".com")
Sod.String().Contains("@")
Sod.String().NonEmpty()
// Transformers (modify the value)
Sod.String().Trim().ToLowerCase()
Sod.String().ToUpperCase()
Numeric validation with range, sign, and divisibility constraints.
// Integer
var ageSchema = Sod.Number().Min(0).Max(150);
var qtySchema = Sod.Number().Positive().MultipleOf(1);
// Decimal (currency)
var priceSchema = Sod.Decimal()
.NonNegative()
.MultipleOf(0.01m); // Two decimal places
// Float
var tempSchema = Sod.Float().Min(-273.15f).Finite();
// Available constraints: Min, Max, Gte, Gt, Lte, Lt
// Positive, Negative, NonPositive, NonNegative, MultipleOf, Finite
// Boolean with coercion: "true"/"1"/"yes"/"on" → true
var boolSchema = Sod.Boolean();
var coercedBool = Sod.Coerce.Boolean(); // Accepts strings
// DateTime with range
var birthdateSchema = Sod.Date()
.Min(new DateTime(1900, 1, 1))
.Max(DateTime.Now);
// Date coercion: accepts DateTime, string, long (Unix ms), int (Unix s)
var coercedDate = Sod.Coerce.Date();
// DateOnly, TimeOnly, TimeSpan
Sod.DateOnly().Min(new DateOnly(2024, 1, 1));
Sod.TimeOnly().Min(new TimeOnly(9, 0));
Sod.Time().Max(TimeSpan.FromHours(8));
public enum Status { Pending, Active, Inactive }
// C# enum - accepts value, string name, or int
var statusSchema = Sod.Enum<Status>();
statusSchema.Parse("Active"); // Status.Active
statusSchema.Parse(1); // Status.Active
statusSchema.Parse(Status.Active); // Status.Active
// Subset of enum values
var activeOnly = Sod.Enum<Status>(Status.Active, Status.Inactive);
// String enum (no C# enum needed)
var roleSchema = Sod.NativeEnum("admin", "user", "guest");
// Literal values
var trueOnly = Sod.Literal(true);
var constant = Sod.Literal("CONST");
var magic = Sod.Literal(42);
Define structured validation for C# classes using Sod.Object<T>(). Fields are defined with expression selectors for type safety.
public class User
{
public string Name { get; set; }
public string Email { get; set; }
public int Age { get; set; }
public string? Bio { get; set; }
}
var userSchema = Sod.Object<User>()
.Field(u => u.Name, Sod.String().Min(1).Max(100))
.Field(u => u.Email, Sod.String().Email())
.Field(u => u.Age, Sod.Number().Min(0).Max(150))
.Field(u => u.Bio, Sod.String().Max(500).Optional());
// Parse from Dictionary
var result = userSchema.Parse(new Dictionary<string, object>
{
{ "Name", "John" },
{ "Email", "john@example.com" },
{ "Age", 30 }
});
| Modifier | Behavior |
|---|---|
| .Strict() | Fail on unknown keys |
| .Strip() | Remove unknown keys (default) |
| .Passthrough() | Allow unknown keys through |
| .Partial() | Make all fields optional |
| .Required() | Make all fields required |
| .Pick("A", "B") | Include only specified fields |
| .Omit("Password") | Exclude specified fields |
// Partial updates (PATCH endpoint)
var updateSchema = userSchema.Partial();
// Response schema (omit sensitive data)
var responseSchema = fullUserSchema.Omit("Password");
// Summary schema (pick specific fields)
var summarySchema = fullUserSchema.Pick("Id", "Name");
Validates a list of elements, each against the provided schema. Output type is List<T>.
var tagsSchema = Sod.Array(Sod.String().NonEmpty())
.Min(1)
.Max(10);
var result = tagsSchema.Parse(new[] { "tag1", "tag2" });
// result.Data = List<string> { "tag1", "tag2" }
Validates unique elements. Duplicates are automatically removed. Output type is HashSet<T>.
Validate dictionary-like structures.
// String-keyed dictionary
var configSchema = Sod.Record(Sod.String());
// With validated keys
var typedConfig = Sod.Record(
Sod.Number(),
Sod.String().Regex(@"^[a-z_]+$")); // Keys: lowercase + underscore
// Tuples
var coordSchema = Sod.Tuple(Sod.Float(), Sod.Float());
Validates against multiple schemas; returns the first successful match.
Uses a discriminator field to select the right schema. Best for polymorphic data.
public abstract class Shape { public string Type { get; set; } }
public class Circle : Shape { public double Radius { get; set; } }
public class Rectangle : Shape {
public double Width { get; set; }
public double Height { get; set; }
}
var shapeSchema = Sod.DiscriminatedUnion<Shape>("Type")
.Option("circle", Sod.Object<Circle>()
.Field(c => c.Radius, Sod.Float().Positive()))
.Option("rectangle", Sod.Object<Rectangle>()
.Field(r => r.Width, Sod.Float().Positive())
.Field(r => r.Height, Sod.Float().Positive()));
Must satisfy ALL schemas. Use .And() to add additional schemas.
Convert validated data from one type to another.
var stringToInt = Sod.Transform(
Sod.String(),
str => int.Parse(str));
// Chain transforms
var doubled = Sod.String()
.Transform(s => int.Parse(s))
.Transform(n => n * 2);
Define schemas that reference themselves for tree-like data structures.
public class Category
{
public string Name { get; set; }
public List<Category>? Subcategories { get; set; }
}
SodSchema<Category>? categorySchema = null;
categorySchema = Sod.Object<Category>()
.Field(c => c.Name, Sod.String())
.Field(c => c.Subcategories,
Sod.Array(Sod.Lazy(() => categorySchema!)).Optional());
var safeNumber = Sod.Catch(Sod.Number().Positive(), 0);
safeNumber.Parse(-5); // Returns 0 (fallback)
safeNumber.Parse("invalid"); // Returns 0 (fallback)
All schemas inherit these methods from SodSchema<T>:
| Method | Description |
|---|---|
| .Optional() | Allows null/undefined input |
| .Nullable() | Allows null input |
| .Default(value) | Provides a default when input is null |
| .Refine(validator, message) | Custom validation with a predicate |
| .Transform<TOut>(func) | Transform validated value to another type |
| .Preprocess(func) | Modify input before validation |
| .Describe(description) | Add metadata description to the schema |
When input comes as strings from HTTP requests or JSON, use Sod.Coerce.* to automatically convert types.
var schema = Sod.Object<MyModel>()
.Field(m => m.Id, Sod.Coerce.Number()) // "123" → 123
.Field(m => m.Active, Sod.Coerce.Boolean()) // "true" → true
.Field(m => m.Created, Sod.Coerce.Date()) // "2024-01-01" → DateTime
.Field(m => m.Price, Sod.Coerce.Decimal()); // "19.99" → 19.99m
// Available coercions:
Sod.Coerce.String() // Any → string
Sod.Coerce.Number() // string/numeric → int
Sod.Coerce.Float() // string/numeric → float
Sod.Coerce.Decimal() // string/numeric → decimal
Sod.Coerce.Boolean() // string/int → bool
Sod.Coerce.Date() // string/timestamp → DateTime
public class LoginForm
{
public string Email { get; set; }
public string Password { get; set; }
public bool RememberMe { get; set; }
}
var loginSchema = Sod.Object<LoginForm>()
.Field(f => f.Email, Sod.String().Email())
.Field(f => f.Password, Sod.String().Min(8))
.Field(f => f.RememberMe, Sod.Boolean().Default(false));
var createUserSchema = Sod.Object<CreateUserRequest>()
.Field(r => r.Username, Sod.String()
.Min(3).Max(20)
.Regex(@"^[a-zA-Z0-9_]+$"))
.Field(r => r.Email, Sod.String().Email())
.Field(r => r.Password, Sod.String()
.Min(8)
.Refine(p => p.Any(char.IsDigit), "Must contain a digit")
.Refine(p => p.Any(char.IsUpper), "Must contain uppercase"))
.Field(r => r.Age, Sod.Number().Min(13).Max(120).Optional())
.Field(r => r.Roles, Sod.Array(
Sod.NativeEnum("admin", "user", "moderator")
).Default(new List<string> { "user" }));
var addressSchema = Sod.Object<Address>()
.Field(a => a.Street, Sod.String().NonEmpty())
.Field(a => a.City, Sod.String().NonEmpty())
.Field(a => a.PostalCode, Sod.String().Regex(@"^\d{5}$"));
var personSchema = Sod.Object<Person>()
.Field(p => p.Name, Sod.String().NonEmpty())
.Field(p => p.Address, addressSchema);
var configSchema = Sod.Object<AppConfig>()
.Field(c => c.Environment,
Sod.NativeEnum("development", "staging", "production"))
.Field(c => c.Port,
Sod.Number().Min(1).Max(65535).Default(8080))
.Field(c => c.ConnectionString,
Sod.String().NonEmpty())
.Field(c => c.EnableLogging,
Sod.Boolean().Default(true))
.Field(c => c.MaxRetries,
Sod.Number().Min(0).Max(10).Default(3));
// Method 1: Check result
var result = schema.Parse(input);
if (result.Success)
{
var data = result.Data;
}
else
{
foreach (var error in result.Errors)
Console.WriteLine(error);
}
// Method 2: Exception-based
try
{
var data = schema.ParseOrThrow(input);
}
catch (SodValidationException ex)
{
foreach (var error in ex.Errors)
Console.WriteLine($"- {error}");
}
// Method 3: Safe with fallback
var safeSchema = Sod.Catch(originalSchema, defaultValue);
var data = safeSchema.Parse(input).Data; // Always succeeds
| Factory Method | Output Type | Key Methods |
|---|---|---|
| Sod.String() | string | Min, Max, Email, Url, Regex, Trim |
| Sod.Number() | int | Min, Max, Positive, MultipleOf |
| Sod.Float() | float | Min, Max, Positive, Finite |
| Sod.Decimal() | decimal | Min, Max, Positive, MultipleOf |
| Sod.Boolean() | bool | Coerce |
| Sod.Date() | DateTime | Min, Max, Coerce |
| Sod.Array(s) | List<T> | Min, Max, Length, NonEmpty |
| Sod.Set(s) | HashSet<T> | Min, Max, Size |
| Sod.Object<T>() | T | Field, Strict, Partial, Pick, Omit |
| Sod.Record(s) | Dictionary<string,T> | - |
| Sod.Enum<T>() | T | - |
| Sod.NativeEnum(...) | string | - |
| Sod.Union(...) | T | Or |
| Sod.DiscriminatedUnion(d) | T | Option |
| Sod.Intersection(...) | T | And |
| Sod.Lazy(f) | T | - |
| Sod.Catch(s, f) | T | - |
.Default() for optional fields with defaults.Refine() for custom validation rules.Strict() when extra keys are errorsSod.Coerce.* for string inputs.ParseOrThrow() in hot paths (exception overhead).Optional() for nullable fieldsSod.Any() unless absolutely necessary