Fight primitive obsession and create expressive domain models with source generators.
PM> Install-Package AndreasDorfer.BaseTypes -Version 1.6.0
A succinct way to create wrappers around primitive types with records and source generators.
using AD.BaseTypes;
using System;
Rating ok = new(75);
try
{
Rating tooHigh = new(125);
}
catch (ArgumentException ex)
{
Console.WriteLine(ex.Message);
//> Parameter must be less than or equal to 100. (Parameter 'value')
//> Actual value was 125.
}
[MinMaxInt(0, 100)] partial record Rating;
//the source generator creates the rest of the record
Consider the following snippet:
class Employee
{
public string Id { get; }
public string DepartmentId { get; }
//more properties
public Department GetDepartment() =>
departmentRepository.Load(DepartmentId);
}
interface IDepartmentRepository
{
Department Load(string id);
}
Both the employee's ID and the associated department's ID are modeled as strings ... although they are logically separate and must never be mixed. What if you accidentally use the wrong ID in GetDepartment
?
public Department GetDepartment() =>
departmentRepository.Load(Id);
Your code still compiles. Hopefully, you've got some tests to catch that bug. But why not utilize the type system to prevent that bug in the first place?
You can use records like single case discriminated unions:
sealed record EmployeeId(string Value);
sealed record DepartmentId(string Value);
class Employee
{
public EmployeeId Id { get; }
public DepartmentId DepartmentId { get; }
//more properties
public Department GetDepartment() =>
departmentRepository.Load(DepartmentId);
}
interface IDepartmentRepository
{
Department Load(DepartmentId id);
}
Now, you get a compiler error when you accidentally use the employee's ID instead of the department's ID. Great! But there's more bugging me: both the employee's and the department's ID must not be empty. The records could reflect that constraint like this:
sealed record EmployeeId
{
public EmployeeId(string value)
{
if(string.IsNullOrEmpty(value)) throw new ArgumentException("must not be empty");
Value = value;
}
public string Value { get; }
}
sealed record DepartmentId
{
public DepartmentId(string value)
{
if(string.IsNullOrEmpty(value)) throw new ArgumentException("must not be empty");
Value = value;
}
public string Value { get; }
}
You get an ArgumentException
whenever you try to create an empty ID. But that's a lot of boilerplate code. There sure is a solution to that:
With AD.BaseTypes
you can write the records like this:
[NonEmptyString] partial record EmployeeId;
[NonEmptyString] partial record DepartmentId;
That's it! All the boilerplate code is generated for you. Here's what the generated code for EmployeeId
looks like:
[TypeConverter(typeof(BaseTypeTypeConverter<EmployeeId, string>))]
[JsonConverter(typeof(BaseTypeJsonConverter<EmployeeId, string>))]
sealed partial record EmployeeId : IComparable<EmployeeId>, IComparable, IBaseType<string>
{
readonly string value;
public EmployeeId(string value)
{
new NonEmptyStringAttribute().Validate(value);
this.value = value;
}
string IBaseType<string>.Value => value;
public override string ToString() => value.ToString();
public int CompareTo(object? obj) => CompareTo(obj as EmployeeId);
public int CompareTo(EmployeeId? other) => other is null ? 1 : Comparer<string>.Default.Compare(value, other.value);
public static explicit operator string(EmployeeId item) => item.value;
public static EmployeeId Create(string value) => new(value);
}
Let's say you need to model a name that's from 1 to 20 characters long:
[MinMaxLengthString(1, 20)] partial record Name;
Or you need to model a serial number that must follow a certain pattern:
[RegexString(@"^\d\d-\w\w\w\w$")] partial record SerialNumber;
The included attributes are:
BoolAttribute
: anybool
DateTimeAttribute
: anyDateTime
DateTimeOffsetAttribute
: anyDateTimeOffset
DecimalAttribute
: anydecimal
DoubleAttribute
: anydouble
GuidAttribute
: anyGuid
IntAttribute
: anyint
MaxIntAttribute
:int
s less than or equal to a maximal valueMaxLengthStringAttribute
:string
s with a maximal character countMinIntAttribute
:int
s greater than or equal to a minimal valueMinLengthStringAttribute
:string
s with a minimal character countMinMaxIntAttribute
:int
s within a rangeMinMaxLengthStringAttribute
:string
s with a character count within a rangeNonEmptyGuidAttribute
: anyGuid
that's not emptyNonEmptyStringAttribute
: anystring
that's not null and not emptyPositiveDecimalAttribute
: positivedecimal
sRegexStringAttribute
:string
s that follow a certain patternStringAttribute
: anystring
that's not null
There are examples in the test code.
The generated types are transparent to the serializer. They are serialized like the types they wrap.
You can create custom attributes. Let's say you need a DateTime
only for weekends:
[AttributeUsage(AttributeTargets.Class)]
class WeekendAttribute : Attribute, IBaseTypeValidation<DateTime>
{
public void Validate(DateTime value)
{
if (value.DayOfWeek != DayOfWeek.Saturday && value.DayOfWeek != DayOfWeek.Sunday)
throw new ArgumentOutOfRangeException(nameof(value), value, "must be a Saturday or Sunday");
}
}
[Weekend] partial record SomeWeekend;
You can apply multiple attributes:
[AttributeUsage(AttributeTargets.Class)]
class YearsAttribute : Attribute, IBaseTypeValidation<DateTime>
{
readonly int from, to;
public YearsAttribute(int from, int to)
{
this.from = from;
this.to = to;
}
public void Validate(DateTime value)
{
if (value.Year < from || value.Year > to)
throw new ArgumentOutOfRangeException(nameof(value), value, $"must be from {from} to {to}");
}
}
[Years(1990, 1999), Weekend] partial record SomeWeekendInThe90s;
The validations happen in the same order as you've applied the attributes. Here's what the generated code for SomeWeekendInThe90s
looks like:
[TypeConverter(typeof(BaseTypeTypeConverter<SomeWeekendInThe90s, DateTime>))]
[JsonConverter(typeof(BaseTypeJsonConverter<SomeWeekendInThe90s, DateTime>))]
sealed partial record SomeWeekendInThe90s : IComparable<SomeWeekendInThe90s>, IComparable, IBaseType<DateTime>
{
readonly DateTime value;
public SomeWeekendInThe90s(DateTime value)
{
new YearsAttribute(1990, 1999).Validate(value);
new WeekendAttribute().Validate(value);
this.value = value;
}
DateTime IBaseType<DateTime>.Value => value;
public override string ToString() => value.ToString();
public int CompareTo(object? obj) => CompareTo(obj as SomeWeekendInThe90s);
public int CompareTo(SomeWeekendInThe90s? other) => other is null ? 1 : Comparer<DateTime>.Default.Compare(value, other.value);
public static explicit operator DateTime(SomeWeekendInThe90s item) => item.value;
public static SomeWeekendInThe90s Create(DateTime value) => new(value);
}
Do you use FsCheck? Check out AD.BaseTypes.Arbitraries
.
PM> Install-Package AndreasDorfer.BaseTypes.Arbitraries -Version 1.6.0
[MinMaxInt(Min, Max), BaseType(Cast.Implicit)]
partial record ZeroToTen
{
public const int Min = 0, Max = 10;
}
const int MinProduct = ZeroToTen.Min * ZeroToTen.Min;
const int MaxProduct = ZeroToTen.Max * ZeroToTen.Max;
MinMaxIntArbitrary<ZeroToTen> arb = new(ZeroToTen.Min, ZeroToTen.Max);
Prop.ForAll(arb, arb, (a, b) =>
{
var product = a * b;
return product >= MinProduct && product <= MaxProduct;
}).QuickCheckThrowOnFailure();
The included arbitraries are:
BoolArbitrary
DateTimeArbitrary
DateTimeOffsetArbitrary
DecimalArbitrary
DoubleArbitrary
ExampleArbitrary
GuidArbitrary
IntArbitrary
MaxIntArbitrary
MaxLengthStringArbitrary
MinIntArbitrary
MinLengthStringArbitrary
MinMaxIntArbitrary
MinMaxLengthStringArbitrary
NonEmptyGuidArbitrary
NonEmptyStringArbitrary
PositiveDecimalArbitrary
StringArbitrary
There are examples in the test code.
Do you want to use the generated types in F#? Check out AD.BaseTypes.FSharp
. The BaseType
and BaseTypeResult
modules offer some useful functions.
PM > Install-Package AndreasDorfer.BaseTypes.FSharp -Version 1.6.0
match (1995, 1, 1) |> DateTime |> BaseType.create<SomeWeekendInThe90s, _> with
| Ok (BaseType.Value dateTime) -> printf "%s" <| dateTime.ToShortDateString()
| Error msg -> printf "%s" msg
You can configure the generator to emit the Microsoft.FSharp.Core.AllowNullLiteral(false)
attribute.
- Add a reference to FSharp.Core.
- Add the file
AD.BaseTypes.Generator.json
to your project:
{
"AllowNullLiteral": false
}
- Add the following
ItemGroup
to your project file:
<ItemGroup>
<AdditionalFiles Include="AD.BaseTypes.Generator.json" />
</ItemGroup>
Du you need model binding support for ASP.NET Core? Check out AD.BaseTypes.ModelBinders
.
PM> Install-Package AndreasDorfer.BaseTypes.ModelBinders -Version 0.11.0
services.AddControllers(options => options.UseBaseTypeModelBinders());
AD.BaseTypes.ModelBinders
is in an early stage.
Do you use Swagger? Check out AD.BaseTypes.OpenApiSchemas
.
PM> Install-Package AndreasDorfer.BaseTypes.OpenApiSchemas -Version 0.11.0
services.AddSwaggerGen(c =>
{
//c.SwaggerDoc(...)
c.UseBaseTypeSchemas();
});
AD.BaseTypes.OpenApiSchemas
is in an early stage.
Do you want to use your primitives in EF Core? Check out AD.BaseTypes.EFCore
.
PM> Install-Package AndreasDorfer.BaseTypes.EFCore -Version 0.11.0
Apply a convention to your DbContext
to tell EF Core how to save and load your primitives to the database.
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Conventions.AddBaseTypeConversionConvention();
}
Your can also configure your types manually
builder.Property(x => x.LastName)
.HasConversion<BaseTypeValueConverter<LastName, string>>();
or overrides the default convention with a custom converter.
builder.Property(x => x.FirstName)
.HasConversion((x) => x + "-custom-conversion", (x) => FirstName.Create(x.Replace("-custom-conversion", "")));