Table of Contents

Class RequiredString<TSelf>

Namespace
Trellis
Assembly
Trellis.Core.dll

Base class for creating strongly-typed string value objects. Rejects only null by default; per-type sentinel rejection ("cannot be empty") and trimming are opt-in via the NotDefaultAttribute and TrimAttribute attributes.

public abstract class RequiredString<TSelf> : ScalarValueObject<TSelf, string>, IComparable<ValueObject>, IComparable, IEquatable<ValueObject>, IConvertible, IFormattable where TSelf : RequiredString<TSelf>, IScalarValue<TSelf, string>

Type Parameters

TSelf
Inheritance
RequiredString<TSelf>
Implements
Derived
Inherited Members
Extension Methods

Examples

Lenient default — only null is rejected:

public partial class FirstName : RequiredString<FirstName> { }

FirstName.TryCreate("John");        // Success("John")
FirstName.TryCreate("");            // Success("") — stored verbatim
FirstName.TryCreate("   ");         // Success("   ") — whitespace preserved
FirstName.TryCreate("  John  ");    // Success("  John  ") — no auto-trim
FirstName.TryCreate(null);          // Failure: "First Name cannot be null."

Strict opt-in equivalent to the pre-realignment defaults — apply TrimAttribute and NotDefaultAttribute together for the recommended default for any string mapped to a database column:

[Trim, NotDefault]
public partial class FirstName : RequiredString<FirstName> { }

// The source generator automatically creates:
// - IScalarValue<FirstName, string> interface implementation
// - public static Result<FirstName> TryCreate(string value)
// - public static Result<FirstName> TryCreate(string? value, string? fieldName = null)
// - public static FirstName Parse(string s, IFormatProvider? provider)
// - public static bool TryParse(string? s, IFormatProvider? provider, out FirstName result)
// - public static explicit operator FirstName(string value)
// - private FirstName(string value) : base(value) { }

FirstName.TryCreate("John");        // Success("John")
FirstName.TryCreate("  John  ");    // Success("John") — trimmed
FirstName.TryCreate("   ");         // Failure: trim → "" → [NotDefault]
FirstName.TryCreate("");            // Failure: "First Name cannot be empty."
FirstName.TryCreate(null);          // Failure: "First Name cannot be null."

// With custom field name for validation errors
FirstName.TryCreate(input, "user.firstName");
// Error field will be "user.firstName" instead of default "firstName"

// Using in entity creation
public class Person : Entity<PersonId>
{
    public FirstName FirstName { get; }
    public LastName LastName { get; }

    public static Result<Person> Create(string firstName, string lastName) =>
        FirstName.TryCreate(firstName)
            .Combine(LastName.TryCreate(lastName))
            .Map((first, last) => new Person(PersonId.NewUniqueV7(), first, last));

    private Person(PersonId id, FirstName firstName, LastName lastName)
        : base(id)
    {
        FirstName = firstName;
        LastName = lastName;
    }
}

ASP.NET Core automatic validation (no manual Result.Combine needed):

// 1. Register automatic validation in Program.cs
builder.Services
    .AddControllers()
    .AddScalarValueObjectValidation(); // Enables automatic validation!

// 2. Define your DTO with value objects
public record RegisterUserDto
{
    public FirstName FirstName { get; init; } = null!;
    public LastName LastName { get; init; } = null!;
    public EmailAddress Email { get; init; } = null!;
}

// 3. Use in controllers - automatic validation!
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
    [HttpPost]
    public IActionResult Register(RegisterUserDto dto)
    {
        // If we reach here, dto is FULLY validated!
        // No Result.Combine() needed - validation happens automatically during model binding
        var user = new User(dto.FirstName, dto.LastName, dto.Email);
        return Ok(user);
    }
}

// Invalid request automatically returns 422 Unprocessable Content:
// POST /api/users with { "firstName": "", "lastName": "Doe", "email": "test@example.com" }
// Response: 422 Unprocessable Content
// {
//   "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
//   "title": "One or more validation errors occurred.",
//   "status": 422,
//   "errors": {
//     "firstName": ["First Name cannot be empty."]
//   }
// }

Using in API validation (manual approach):

// Request DTO
public record CreateUserRequest(string FirstName, string LastName, string Email);

// API endpoint with automatic validation
app.MapPost("/users", (CreateUserRequest request) =>
    FirstName.TryCreate(request.FirstName, nameof(request.FirstName))
        .Combine(LastName.TryCreate(request.LastName, nameof(request.LastName)))
        .Combine(EmailAddress.TryCreate(request.Email, nameof(request.Email)))
        .Bind((first, last, email) => User.Create(first, last, email))
        .ToHttpResponse());

// POST /users with empty FirstName:
// Response: 422 Unprocessable Content
// {
//   "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
//   "title": "One or more validation errors occurred.",
//   "status": 422,
//   "errors": {
//     "firstName": ["First Name cannot be empty."]
//   }
// }

Multiple string-based value objects:

public partial class FirstName : RequiredString<FirstName> { }
public partial class LastName : RequiredString<LastName> { }
public partial class CompanyName : RequiredString<CompanyName> { }
public partial class ProductName : RequiredString<ProductName> { }
public partial class Description : RequiredString<Description> { }

public class Product : Entity<ProductId>
{
    public ProductName Name { get; private set; }
    public Description Description { get; private set; }

    public Result<Product> UpdateName(ProductName newName) =>
        Result.Ok(newName)
            .Tap(name => Name = name)
            .Map(_ => this);

    // Compiler prevents mixing types:
    // UpdateName(description); // Won't compile!
    // UpdateName(firstName);   // Won't compile!
}

Advanced: Adding custom validation to derived types:

// Use [StringLength] for length, add custom validation for format rules
[StringLength(20)]
public partial class ProductSKU : RequiredString<ProductSKU>
{
    // Additional format validation via custom factory method
    public static Result<ProductSKU> TryCreateWithValidation(string? value) =>
        TryCreate(value) // Generated: validates non-empty + length <= 20
            .Ensure(sku => sku.Value.All(c => char.IsLetterOrDigit(c) || c == '-'),
                   new Error.InvalidInput(EquatableArray.Create(new FieldViolation(InputPointer.ForProperty("sku"), ValidationCodes.StringPattern) { Detail = "SKU can only contain letters, digits, and hyphens" })));
}

// Usage
var result = ProductSKU.TryCreateWithValidation("PROD-12345");
// Success

var tooLong = ProductSKU.TryCreateWithValidation(new string('A', 21));
// Failure: "Product SKU must be 20 characters or fewer."

var invalid = ProductSKU.TryCreateWithValidation("PROD@12345");
// Failure: "SKU can only contain letters, digits, and hyphens"

String length constraints with StringLengthAttribute:

// Maximum length only — rejects strings longer than 50 characters
[StringLength(50)]
public partial class FirstName : RequiredString<FirstName> { }

var ok = FirstName.TryCreate("John");      // Success
var tooLong = FirstName.TryCreate(new string('x', 51)); // Failure: "First Name must be 50 characters or fewer."

// Both minimum and maximum length
[StringLength(500, MinimumLength = 10)]
public partial class Description : RequiredString<Description> { }

var tooShort = Description.TryCreate("Hi");        // Failure: "Description must be at least 10 characters."
var tooLong2 = Description.TryCreate(new string('x', 501)); // Failure: "Description must be 500 characters or fewer."

Remarks

This class extends ScalarValueObject<TSelf, T> to provide a specialized base for string-based value objects. When used with the partial keyword, the PrimitiveValueObjectGenerator source generator automatically creates:

  • IScalarValue<TSelf, string> implementation for ASP.NET Core automatic validation
  • TryCreate(string) - Factory method for non-nullable strings (required by IScalarValue)
  • TryCreate(string?, string?) - Factory method with null-only rejection by default (add NotDefaultAttribute for empty rejection and TrimAttribute for trim) and custom field name
  • IParsable<T> implementation (Parse, TryParse)
  • JSON serialization support via ParsableJsonConverter<T>
  • Explicit cast operator from string
  • OpenTelemetry activity tracing

Common use cases:

  • Person names (FirstName, LastName, FullName)
  • Product attributes (ProductName, Description, SKU)
  • Location data (City, State, Country, PostalCode)
  • Business identifiers (CompanyName, TaxId, AccountNumber)
  • Any domain concept represented by required text

String length constraints: Apply the StringLengthAttribute to enforce minimum and/or maximum lengths at creation time:

[StringLength(50)]
public partial class FirstName : RequiredString<FirstName> { }

[StringLength(500, MinimumLength = 10)]
public partial class Description : RequiredString<Description> { }

Benefits over plain strings:

  • Type safety: Cannot mix FirstName with LastName
  • Validation: Prevents null at creation time; opt into empty / whitespace rejection with [NotDefault] / [Trim]
  • Length constraints: Optional min/max length via StringLengthAttribute
  • Domain clarity: Self-documenting code that expresses intent
  • Consistency: Centralized normalization via opt-in attributes ([Trim], [NotDefault], [StringLength]) and the ValidateAdditional hook
  • Testability: Easy to test validation rules in isolation

Constructors

RequiredString(string)

Initializes a new instance of the RequiredString<TSelf> class with the specified string value.

protected RequiredString(string value)

Parameters

value string

The string value.

Remarks

This constructor is protected and should be called by derived classes. When using the source generator (with partial keyword), a private constructor is automatically generated.

Direct instantiation should be avoided. Instead, use the generated factory method:

  • TryCreate(string?, string?) - Create from string with validation

The generated TryCreate method always rejects null with "<FieldName> cannot be null.". Additional behavior is opt-in per attribute:

  • TrimAttribute — trims leading and trailing whitespace before any subsequent check.
  • NotDefaultAttribute — rejects Empty (operates on the post-trim value when [Trim] is also present).
  • StringLengthAttribute — applies minimum and / or maximum length bounds; measures the post-trim value when [Trim] is present, the raw input otherwise.

Applying [Trim, NotDefault] together is the recommended setup for any string mapped to a database column and recovers the pre-realignment "reject null + empty + whitespace; auto-trim" default.

Properties

Length

Gets the length of the string value. Enables natural LINQ queries like c.Name.Length > 5 without accessing .Value.

public int Length { get; }

Property Value

int

Methods

Contains(char, StringComparison)

Returns whether the string value contains the specified character using the supplied comparison. This overload is not EF Core translatable; use the single-argument overload for queries.

public bool Contains(char value, StringComparison comparisonType)

Parameters

value char
comparisonType StringComparison

Returns

bool

Contains(string)

Returns whether the string value contains the specified substring. Enables natural LINQ queries like c.Name.Contains("li") without accessing .Value.

public bool Contains(string value)

Parameters

value string

The substring to test for.

Returns

bool

true when the value contains value.

Remarks

Evaluated in memory this is ordinal, because Contains(string) compares by code unit. That makes it disagree with StartsWith(string) and EndsWith(string), which are culture-sensitive. The difference is observable whenever the data contains characters a culture treats as ignorable, such as the soft hyphen U+00AD — note that the same argument yields opposite answers:

var name = ProductName.Create("cooper\u00ADative");
name.EndsWith("rative");  // true  — culture-sensitive, ignores the soft hyphen
name.Contains("rative");  // false — ordinal, the soft hyphen is a real character
name.StartsWith("coopera"); // true, for the same reason as EndsWith

Translated to SQL by EF Core the comparison is performed by the database using the provider's own string-matching functions and collations, which may match neither and are provider-specific. Use Contains(string, StringComparison) when the semantics must be explicit and the call is not part of a query.

Contains(string, StringComparison)

Returns whether the string value contains the specified substring using the supplied comparison. This overload is not EF Core translatable; use the single-argument overload for queries.

public bool Contains(string value, StringComparison comparisonType)

Parameters

value string
comparisonType StringComparison

Returns

bool

EndsWith(string)

Returns whether the string value ends with the specified suffix. Enables natural LINQ queries like c.Name.EndsWith("ce") without accessing .Value.

public bool EndsWith(string value)

Parameters

value string

The suffix to test for.

Returns

bool

true when the value ends with value.

Remarks

Evaluated in memory this is culture-sensitive, because EndsWith(string) compares using CurrentCulture. It therefore does not agree with Contains(string), which is ordinal — see Contains(string) for a worked example of the disagreement.

Translated to SQL by EF Core the comparison is performed by the database using the provider's own string-matching functions and collations, which may match neither and are provider-specific. Use EndsWith(string, StringComparison) when the semantics must be explicit and the call is not part of a query.

EndsWith(string, StringComparison)

Returns whether the string value ends with the specified suffix using the supplied comparison. This overload is not EF Core translatable; use the single-argument overload for queries.

public bool EndsWith(string value, StringComparison comparisonType)

Parameters

value string
comparisonType StringComparison

Returns

bool

StartsWith(string)

Returns whether the string value starts with the specified prefix. Enables natural LINQ queries like c.Name.StartsWith("Al") without accessing .Value.

public bool StartsWith(string value)

Parameters

value string

The prefix to test for.

Returns

bool

true when the value starts with value.

Remarks

Evaluated in memory this is culture-sensitive, because StartsWith(string) compares using CurrentCulture. It therefore does not agree with Contains(string), which is ordinal — see Contains(string) for a worked example of the disagreement.

Translated to SQL by EF Core the comparison is performed by the database using the provider's own string-matching functions and collations, which may match neither and are provider-specific. Use StartsWith(string, StringComparison) when the semantics must be explicit and the call is not part of a query.

StartsWith(string, StringComparison)

Returns whether the string value starts with the specified prefix using the supplied comparison. This overload is not EF Core translatable; use the single-argument overload for queries.

public bool StartsWith(string value, StringComparison comparisonType)

Parameters

value string
comparisonType StringComparison

Returns

bool

See Also