This library allows you to generate your own primitives with very little overhead. The code generation integrates with the build pipeline. You create a partial struct
, decorate it with an attribute and the code generator takes care of the rest.
Consider this ficticious order entity:
class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public IEnumerable<int> ItemIds { get; set; }
public DateTimeOffset Timestamp { get; set; }
public OrderStatus Status { get; set; }
}
Here all the IDs are integers. This is an example of primitive obsession where the same primitive type (int
) is used to represent values that have different types. Order IDs should not be mixed with customer IDs and none of these should be mixed with order item IDs.
Instead you can introduce distinct types:
class Order
{
public OrderId Id { get; set; }
public CustomerId CustomerId { get; set; }
public IEnumerable<OrderItemId> ItemIds { get; set; }
public DateTimeOffset Timestamp { get; set; }
public OrderStatus Status { get; set; }
}
The IDs are very simple. This is the OrderId
:
readonly struct OrderId : IEquatable<OrderId>
{
readonly int value;
public OrderId(int value) => this.value = value;
public bool Equals(OrderId other) => Equals(value, other.value);
public override bool Equals(object obj) => obj is OrderId orderId && Equals(orderId);
public override int GetHashCode() => value.GetHashCode();
}
The other IDs use the same template.
These primitives behave the same way as the built-in primtives of C# like int
, long
, Guid
and string
. In principle the JITed code for an int
ID and an ID that wraps an int
in a readonly struct
should be the same but in practice the struct
may have a slight overhead. However, in most cases this overhead shouldn't matter.
Add a reference to Liversage.Primitives
(this is a .NET source generator — they were introduced in .NET 5.0). Then create your primitive type as a readonly partial struct
with a field:
[Primitive]
readonly partial struct OrderId
{
readonly int id;
}
Adding the [Primitive]
attribute generates a source file that becomes part of the project. Normally you should not care about this file that is automatically included in your build but to better understand the code generated it's instructive to look at it:
readonly partial struct OrderId : IEquatable<OrderId>
{
public OrderId(int id) => this.id = id;
public static OrderId FromInt32(int id) => new OrderId(id);
public static implicit operator OrderId(int id) => FromInt32(id);
public int ToInt32() => id;
public static explicit operator int (OrderId id) => id.ToInt32();
public override string ToString() => id.ToString();
public bool Equals(OrderId other) => this.id == other.id;
public override bool Equals(object obj) => obj is OrderId value && Equals(value);
public override int GetHashCode() => id.GetHashCode();
public static bool operator ==(OrderId value1, OrderId value2) => value1.Equals(value2);
public static bool operator !=(OrderId value1, OrderId value2) => !(value1 == value2);
}
The code generator adds members to the struct
so you can use it just like you would use an int
. It creates an implicit cast so you can use an int
where an OrderId
is required:
Order GetOrderById(OrderId id) { ... }
// The int 123 is implicitly cast to an OrderId.
var order = GetOrderById(123);
You have to use an explicit cast to do it the other way:
void UpdateOrderExternal(int id) { ... }
// Use explicit cast to convert OrderId to int.
UpdateOrderExternal((int) orderId);
// Or use the To... method.
UpdateOrderExternal(orderId.ToInt32());
Want to use a long
instead of an int
? Modify the partial struct
:
[Primitive]
readonly partial struct OrderId
{
readonly long id;
}
The code generator will update the generated methods to match the new type of the field. Instead of a value type like int
or long
you can use string
:
[Primitive]
readonly partial struct OrderId
{
readonly string id;
}
The generated code becomes slightly different because string
is a reference type which might be null
.
The code generator supports the following inner types:
sbyte
byte
short
ushort
int
uint
long
ulong
decimal
float
double
char
DateTime
DateTimeOffset
TimeSpan
Guid
- Most immutable
struct
s Nullable<T>
whereT
is supportedstring
The [Primitive]
attribute has an optional Features
parameter:
This is the baseline used by the code generator. The following members will be generated:
- A constructor that constructs a primitve from an instance of the inner type.
- A static
From...
method (e.g.FromInt32
) that converts an instance of the inner type to a primitive. - An implicit cast that casts an instance of the inner type to a primitive.
- A
To...
method (e.g.ToInt32
) that converts a primtive to an instance of the inner type. - An explicit cast that casts a primtive to an instance of the inner type.
- A
ToString
method that delegates to the same method of the inner type.
The From...
and To...
methods will be named so they match the inner type. However, C# has the concept of type keywords where int
can be used instead of System.Int32
. Unfortunately, using the type keyword to create a method name doesn't work so well so instead the name of the type is used. This means that the method names will be FromInt32
and ToInt32
and not Fromint
and Toint
when the inner type is int
. For DateTime
the names will unsuprisingly be FromDateTime
and ToDateTime
etc.
This is the default and extends the members generated by Features.None
by implementing IEquatable<T>
:
- The
IEquatable<T>
interface is implemented by using the==
operator of the inner type. object.Equals
is overriden based onIEquatable<T>.Equals
.object.GetHashCode
is overriden and delegates toGetHashCode
of the inner type.- Operators
==
and!=
are created based onIEquatable<T>
.
This provides supports for string formatting:
- The
IFormattable
interface is implemented by delegatingToString(string format, CultureInfo cultureInfo)
to the inner type.
This provides support for parsing strings:
- Add static method
TryParse
that parses astring
by delegating to the inner type. - Add static method
TryParse
that parses aReadOnlySpan<char>
by delegating to the inner type.
Only the following inner types supports Features.Parsable
:
sbyte
byte
short
ushort
int
uint
long
ulong
decimal
float
double
DateTime
DateTimeOffset
TimeSpan
The TryParse
methods for DateTime
and DateTimeOffset
delegate to TryParseExact
with a single format string.
This provides support for converting to other types using the static Convert
class:
- The
IConvertible
interface is implemented by delegating to the inner type.
When the inner type is string
values are by default compared using StringComparison.Ordinal
. However, another StringComparison
can be spcified in the [Primitive]
attribute:
[Primitive(StringComparison = StringComparison.OrdinalIgnoreCase)]
readonly partial struct Keyword
{
readonly string keyword;
}
If you provide a constructor in the partial struct
no constructor will be generated. The same applies to the ToString
method. You can use that to provide validation:
[Primitive(StringComparison = StringComparison.OrdinalIgnoreCase)]
readonly partial struct Currency
{
readonly string currency;
public Currency(string currency)
{
if (!IsValid(currency))
throw new ArgumentException("Invalid currency.", nameof(currency));
this.currency = currency;
}
public override string ToString() => currency.ToUpperInvariant();
public static bool IsValid(string currency) => currency?.Length is 3 && currency.All(char.IsLetter);
}
You can use Currency
as a primitive type. The following expression is true
:
Currency.FromString("eur") == Currency.FromString("EUR")
Notice that a struct
always has a default constructor that will initialize the field to its default value (0, null
etc.). When this constructor is used (e.g when creating arrays) no validation is performed. Even if you disallow the field to have the default value you should be prepared to handle this value in case the default constructor is used.
A lot of processing in software systems happens at the edge where domain types are serialized to formats like JSON or storage like a relational database. JSON serializers and OR frameworks understand types like int
and string
but don't understand the primitive OrderId
. If you are using DTOs at the edge you will often use an object mapper to convert between domain models and DTOs and chances are that this mapper doesn't understand primitives like OrderId
.
Fortunately many serializers, OR frameworks and object mappers are extensible and allow custom converters to be used but unfortunately you will have to create these converters yourself. One might argue that since this library already uses code generation it should also code generate relevant converters. This is true but the scope of doing this is very wide and is not included (yet?).
The code generator has certain expectations about the struct
that [Primitive]
is attached to. If these expectations are not met it will provide some diagnostic output describing the problem or perhaps in some cases just crash. Either way you get compiler warnings or errors but the errors with long stack traces are not so easy to understand compared to the diagnostics messages so there might be room for improvement.
This project was created before .NET source generators were available and initially used CodeGeneration.Roslyn
to perform the code generation.