Noundry.Sod

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.

.NET 8.0+ Zero Dependencies MIT License
# Install via NuGet
$ dotnet add package Noundry.Sod

Quick Start

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);
}

Core Concepts

Immutable Schemas

All schemas follow an immutable, fluent builder pattern. Define once, reuse everywhere.

Result Pattern

SodResult<T> returns Success, Data, and Errors -- no exceptions in normal flow.

Type Coercion

Use Sod.Coerce.* to automatically convert string inputs from HTTP/JSON to typed values.

Composable

Nest schemas inside objects, arrays, unions, and transforms for complex data structures.

Parsing Methods

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

Primitive Schemas

Sod.String()

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()

Sod.Number() / Sod.Float() / Sod.Decimal()

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

Sod.Boolean() / Sod.Date()

// 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));

Enums and Literals

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);

Object Schemas

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 }
});

Object Mode Modifiers

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");

Collection Schemas

Sod.Array<T>(elementSchema)

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" }

Sod.Set<T>(elementSchema)

Validates unique elements. Duplicates are automatically removed. Output type is HashSet<T>.

Sod.Record<T>(valueSchema) / Sod.Map<K,V>(keySchema, valueSchema)

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());

Unions and Intersections

Sod.Union<T>(schemas...)

Validates against multiple schemas; returns the first successful match.

Sod.DiscriminatedUnion<T>(discriminator)

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()));

Sod.Intersection<T>(schemas...)

Must satisfy ALL schemas. Use .And() to add additional schemas.

Transforms and Utilities

Transform

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);

Lazy (Recursive Schemas)

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());

Catch (Fallback Values)

var safeNumber = Sod.Catch(Sod.Number().Positive(), 0);
safeNumber.Parse(-5); // Returns 0 (fallback)
safeNumber.Parse("invalid"); // Returns 0 (fallback)

Base Schema Methods

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

Type Coercion

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

Real-World Patterns

Form Validation

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));

API Request with Password Rules

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" }));

Nested Object Validation

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);

Configuration Validation

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));

Error Handling

// 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

Schema Type Reference

Factory Method Output Type Key Methods
Sod.String()stringMin, Max, Email, Url, Regex, Trim
Sod.Number()intMin, Max, Positive, MultipleOf
Sod.Float()floatMin, Max, Positive, Finite
Sod.Decimal()decimalMin, Max, Positive, MultipleOf
Sod.Boolean()boolCoerce
Sod.Date()DateTimeMin, Max, Coerce
Sod.Array(s)List<T>Min, Max, Length, NonEmpty
Sod.Set(s)HashSet<T>Min, Max, Size
Sod.Object<T>()TField, Strict, Partial, Pick, Omit
Sod.Record(s)Dictionary<string,T>-
Sod.Enum<T>()T-
Sod.NativeEnum(...)string-
Sod.Union(...)TOr
Sod.DiscriminatedUnion(d)TOption
Sod.Intersection(...)TAnd
Sod.Lazy(f)T-
Sod.Catch(s, f)T-

Best Practices

Do

  • Reuse schemas -- define once, use many times
  • Use .Default() for optional fields with defaults
  • Use .Refine() for custom validation rules
  • Use .Strict() when extra keys are errors
  • Use Sod.Coerce.* for string inputs

Avoid

  • Using .ParseOrThrow() in hot paths (exception overhead)
  • Forgetting .Optional() for nullable fields
  • Using Sod.Any() unless absolutely necessary
  • Mutating schemas after creation

Related Packages