base-types
base-types copied to clipboard
Fight primitive obsession and create expressive domain models with source generators.
AD.BaseTypes
Fight primitive obsession and create expressive domain models with source generators.
NuGet Package
PM> Install-Package AndreasDorfer.BaseTypes -Version 1.6.0
TLDR
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
Motivation
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:
Source Generation
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);
}
But there's more!
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;
Included Attributes
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 value -
MaxLengthStringAttribute
:string
s with a maximal character count -
MinIntAttribute
:int
s greater than or equal to a minimal value -
MinLengthStringAttribute
:string
s with a minimal character count -
MinMaxIntAttribute
:int
s within a range -
MinMaxLengthStringAttribute
:string
s with a character count within a range -
NonEmptyGuidAttribute
: anyGuid
that's not empty -
NonEmptyStringAttribute
: anystring
that's not null and not empty -
PositiveDecimalAttribute
: positivedecimal
s -
RegexStringAttribute
:string
s that follow a certain pattern -
StringAttribute
: anystring
that's not null
There are examples in the test code.
JSON Serialization
The generated types are transparent to the serializer. They are serialized like the types they wrap.
Custom Attributes
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;
Multiple Attributes
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);
}
Arbitraries
Do you use FsCheck? Check out AD.BaseTypes.Arbitraries
.
NuGet Package
PM> Install-Package AndreasDorfer.BaseTypes.Arbitraries -Version 1.6.0
Example
[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();
Included Arbitraries
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.
F#
Do you want to use the generated types in F#? Check out AD.BaseTypes.FSharp
. The BaseType
and BaseTypeResult
modules offer some useful functions.
NuGet Package
PM > Install-Package AndreasDorfer.BaseTypes.FSharp -Version 1.6.0
Example
match (1995, 1, 1) |> DateTime |> BaseType.create<SomeWeekendInThe90s, _> with
| Ok (BaseType.Value dateTime) -> printf "%s" <| dateTime.ToShortDateString()
| Error msg -> printf "%s" msg
Options
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>
ASP.NET Core
Du you need model binding support for ASP.NET Core? Check out AD.BaseTypes.ModelBinders
.
NuGet Package
PM> Install-Package AndreasDorfer.BaseTypes.ModelBinders -Version 0.11.0
Configuration
services.AddControllers(options => options.UseBaseTypeModelBinders());
Note
AD.BaseTypes.ModelBinders
is in an early stage.
Swagger
Do you use Swagger? Check out AD.BaseTypes.OpenApiSchemas
.
NuGetPackage
PM> Install-Package AndreasDorfer.BaseTypes.OpenApiSchemas -Version 0.11.0
Configuration
services.AddSwaggerGen(c =>
{
//c.SwaggerDoc(...)
c.UseBaseTypeSchemas();
});
Note
AD.BaseTypes.OpenApiSchemas
is in an early stage.
Entity Framework Core
Do you want to use your primitives in EF Core? Check out AD.BaseTypes.EFCore
.
NuGetPackage
PM> Install-Package AndreasDorfer.BaseTypes.EFCore -Version 0.11.0
Configuration
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", "")));