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
-
ScalarValueObject<TSelf, string>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 validationTryCreate(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 nameIParsable<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 theValidateAdditionalhook - 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
valuestringThe 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
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
valuecharcomparisonTypeStringComparison
Returns
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
valuestringThe substring to test for.
Returns
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
valuestringcomparisonTypeStringComparison
Returns
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
valuestringThe suffix to test for.
Returns
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
valuestringcomparisonTypeStringComparison
Returns
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
valuestringThe prefix to test for.
Returns
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
valuestringcomparisonTypeStringComparison