Vlad DX
Posted on January 20, 2023
Aren't we all tired of STRINGLY-typed values? Have you heard about Primitive Obsession anti-pattern?
It's time to stop the suffering (at least in a strongly-typed .NET world).
Make compiler your friend, introduce semantics to your code. Eliminate stringly-typed interfaces.
Example #1
Imagine, we need to call an API. There is an endpoint that returns info about a TV show. We start with exploratory testing and make an API call.
GET https://api.tvmaze.com/shows/1
TVmaze API is licensed by CC BY-SA.
{
"id": 1,
"url": "https://www.tvmaze.com/shows/1/under-the-dome",
"name": "Under the Dome",
"type": "Scripted",
"language": "English",
"genres": [
"Drama",
"Science-Fiction",
"Thriller"
],
"status": "Ended",
"runtime": 60,
"averageRuntime": 60,
"premiered": "2013-06-24",
"ended": "2015-09-10",
"officialSite": "http://www.cbs.com/shows/under-the-dome/",
"schedule": {
"time": "22:00",
"days": [
"Thursday"
]
},
"rating": {
"average": 6.5
},
"weight": 98,
"network": {
"id": 2,
"name": "CBS",
"country": {
"name": "United States",
"code": "US",
"timezone": "America/New_York"
},
"officialSite": "https://www.cbs.com/"
},
"webChannel": null,
"dvdCountry": null,
"externals": {
"tvrage": 25988,
"thetvdb": 264492,
"imdb": "tt1553656"
},
"image": {
"medium": "https://static.tvmaze.com/uploads/images/medium_portrait/81/202627.jpg",
"original": "https://static.tvmaze.com/uploads/images/original_untouched/81/202627.jpg"
},
"summary": "<p><b>Under the Dome</b> is the story of a small town that is suddenly and inexplicably sealed off from the rest of the world by an enormous transparent dome. The town's inhabitants must deal with surviving the post-apocalyptic conditions while searching for answers about the dome, where it came from and if and when it will go away.</p>",
"updated": 1631010933,
"_links": {
"self": {
"href": "https://api.tvmaze.com/shows/1"
},
"previousepisode": {
"href": "https://api.tvmaze.com/episodes/185054"
}
}
}
What do we usually do next?
Right, we go to something like https://json2csharp.com and copy-pasting the JSON there. As a result, we've got a nice DTO that we can use in our C# code.
If we do minor fixes to the root class and property names casing, it'll look something like this:
// Show show = JsonConvert.DeserializeObject<Show>(json);
public class Show
{
public int Id { get; set; }
public string Url { get; set; }
public string Name { get; set; }
public string Type { get; set; }
public string Language { get; set; }
public List<string> Genres { get; set; }
public string Status { get; set; }
public int Runtime { get; set; }
public int AverageRuntime { get; set; }
public string Premiered { get; set; }
public string Ended { get; set; }
public string OfficialSite { get; set; }
public Schedule Schedule { get; set; }
public Rating Rating { get; set; }
public int Weight { get; set; }
public Network Network { get; set; }
public object WebChannel { get; set; }
public object DvdCountry { get; set; }
public Externals Externals { get; set; }
public Image Image { get; set; }
public string Summary { get; set; }
public int Updated { get; set; }
public Links _links { get; set; }
}
public class Country
{
public string Name { get; set; }
public string Code { get; set; }
public string Timezone { get; set; }
}
public class Externals
{
public int TvRage { get; set; }
public int TheTvDb { get; set; }
public string Imdb { get; set; }
}
public class Image
{
public string Medium { get; set; }
public string Original { get; set; }
}
public class Links
{
public Self Self { get; set; }
public PreviousEpisode PreviousEpisode { get; set; }
}
public class Network
{
public int Id { get; set; }
public string Name { get; set; }
public Country Country { get; set; }
public string OfficialSite { get; set; }
}
public class PreviousEpisode
{
public string Href { get; set; }
}
public class Rating
{
public double Average { get; set; }
}
public class Schedule
{
public string Time { get; set; }
public List<string> Days { get; set; }
}
public class Self
{
public string Href { get; set; }
}
Do you see the problem now?
All values are primitive:
- Can you tell the difference between
Scripted
andEnglish
? - What about
Ended
andDrama
? - Some of them can be not
string
butint
. Does it help? What's the difference between ID with value1
and Runtime with value60
?
It's very easy to mix up two values of the same type whether they are string
or int
.
Humans make errors. And compilers are here to help. But it can help with a bunch of string
values.
If a developer mixes up Drama
and English
, the problem can be propagated to Production and might be discovered by an end-user š¢
It's bad when users report problems. It's very late and quite expensive to fix. The earlier we discover issues, the better. The earliest possible time is when a developer typing the code and compiler checks it on the fly in the IDE.
Example #2
Let's say we are working on an integration between two systems. We fetch an entity from API #1 and put it to API #2.
GET https://service1/api/orders/2a14d479-0d74-4742-91b6-fbae62ddc017
{ "orderId": "2a14d479-0d74-4742-91b6-fbae62ddc017", "orderTrackingId": "2bec51f6-f11f-476e-b336-d94426d25a55", "productId": "31ef8a4c-face-49dd-b521-fc930a026848", "personId": "f1d9f71d-dd0c-444e-9537-2a301fb94e1f" }
POST https://target-service/api/orders/2bec51f6-f11f-476e-b336-d94426d25a55
{ "order": { "orderId": "2a14d479-0d74-4742-91b6-fbae62ddc017", "orderTrackingId": "2bec51f6-f11f-476e-b336-d94426d25a55", "personId": "31ef8a4c-face-49dd-b521-fc930a026848", "productId": "f1d9f71d-dd0c-444e-9537-2a301fb94e1f" } }
Have you spotted the problem? š
Have you found another one? š²
You might think it's a silly example. But it's not.
It happens.
Humans make mistakes and IDE can unintentionally help with that.
How could it happen?
You might ask:
"How is it possible?! I am an experienced developer, I don't make silly mistakes".
We all do.
First bug
I was typing ProductId
, I was tired, IDE suggested an auto-completion, and I agreed:
š„ Boom. We've got a PersonId
instead of a ProductId
.
- They are both GUIDs,
- look similar,
- same length,
- start with
P
, - both IDs.
Second bug
If you haven't noticed, there is another less discoverable bug.
We send a POST request with the wrong ID. I did the same thing. I was trying to type .OrderId
but somehow ended up with .OrderTrackingId
.
It's even harder to notice.
šāāļø Hey, compiler! Where are you, my friend? Why don't you help me here? Why are you so dumb? (A mean person might say)
What if the compiler were smarter?
Should we blame compiler? I don't think so.
Can we do better? Sure, we can šŖ
We, as developers, can help the compiler to distinguish PersonId
and ProductId
. Despite both being GUIDs, they have completely different meanings.
And complier is very good with semantics. It just requires some small help.
How does compiler distinguish 1.5
and "1.5"
? There is a type system in the language:
-
1.5
is afloat
-
"1.5"
is astring
What if C# would allow us to make different aliases for the same types? And what if it will treat them as different types then?
var personId = new PersonId("f1d9f71d-dd0c-444e-9537-2a301fb94e1f");
var productId = new ProductId("31ef8a4c-face-49dd-b521-fc930a026848");
personId = productId;
// ^ Compile-time error: Cannot convert source type 'ProductId' to target type 'PersonId'
That's what I expect from a nice compiler. But without too much hassle from my side.
Can we do it? āāā
Wonderfully-typed world
That is a healthy strongly-typed class. Easy job for compiler to help me to spot mistakes immediately, in a blink of an eye š¤©
public class Show
{
public ShowId Id { get; set; }
public ShowUri Url { get; set; }
public ShowName Name { get; set; }
public ShowType Type { get; set; }
public ShowLanguage Language { get; set; }
public List<Genres> Genres { get; set; }
public ShowStatus Status { get; set; }
public Runtime Runtime { get; set; }
public Runtime AverageRuntime { get; set; }
public PremieredDate Premiered { get; set; }
public EndedDate Ended { get; set; }
public OfficialSiteUri OfficialSite { get; set; }
public Schedule Schedule { get; set; }
public Rating Rating { get; set; }
public Weight Weight { get; set; }
public Network Network { get; set; }
public WebChannel WebChannel { get; set; }
public DvdCountry DvdCountry { get; set; }
public Externals Externals { get; set; }
public Image Image { get; set; }
public string Summary { get; set; }
public UpdatedTimestamp Updated { get; set; }
public Links _links { get; set; }
}
What does it take on my side to enable that?
I just need to do a little boring job. I need to create a class per type and do a little magic.
[StrongType(typeof(int))]
public partial class ShowId
{
}
[StrongType(typeof(int))]
public partial class PersonId
{
}
[StrongType(typeof(Uri))]
public partial class ShowUri
{
}
[StrongType] // `string` by default
public partial class ShowName
{
}
// ... And so on
And you will never mix up Show ID and Person ID again.
š Just use the Xtz.StronglyTyped NuGet package. E-a-s-y:
<PackageReference Include="Xtz.StronglyTyped" Version="0.23.0" />
This NuGet package is a practical solution for the primitive obsession problem.
What a magical thing! āØ
Somebody was obsessed with the primitive obsession, so they took some time to figure out how to do the magic.
Behind [StrongType]
attribute, there is some trickery done.
Trick is possible because of Roslyn ā a modern C# compiler. It was introduced back in 2014 in Visual Studio 2015 RTM.
There is a very useful Roslyn feature ā Source Generators. You can create a Source Generator and plug it in to your C# project (as a Roslyn analyzer).
The Source Generator kicks in before compilation process, analyzes your code, finds all types with [StrongType]
attribute, generates some C# code for them. And then the code is compiled.
In such a way, we can do a lot of compiler-time magic to facilitate strongly-typed values and eliminate primitive obsession.
The compiler is our best friend again š
More examples
ā Strongly-typed GUID-based IDs
Just use EmployeeGuidId
to generate new GUIDs and keep the types strong. Or parse it from string
, or create it from Guid
.
[StrongType(typeof(Guid))]
public partial class EmployeeGuidId : GuidId
{
}
// ...
// Generating a new GUID
var employee1Id = new EmployeeGuidId(); // 58a221d3-59e3-4038-ae26-5b728d331b8b
// Generating a new GUID
var employee2Id = new EmployeeGuidId(); // 92ad2a40-994f-4e92-b427-db8f707699bd
// Parsing a GUID from a string value
var employee3Id = new EmployeeGuidId("cd5944c7-a649-41c6-a8e9-b4dd218f6b1f");
// Passing GUID to constructor
employee3Id = new EmployeeGuidId(Guid.Parse("cd5944c7-a649-41c6-a8e9-b4dd218f6b1f"));
// Creating the same IDs
var id = "1169883c-5f0b-4b65-81cb-65c86ce5794d";
var id1 = new EmployeeGuidId(guid);
var id2 = new EmployeeGuidId(guid);
// Comparing them
Console.WriteLine(id1 == id2); // True
ā Strongly-typed int-based IDs
Just convert an int
to an Employee ID when needed.
[StrongType(typeof(int))]
public partial class EmployeeIntId : IntId
{
}
// ...
// Explicit conversion
var employee1Id = (EmployeeIntId)4682;
// Creation using constructor
var employee2Id = new EmployeeIntId(3958);
// Back to primitives: Convert to `int` (just in case if needed)
var intId1 = Convert.ToInt32(employee2Id); // 3958
// Back to primitives: Explicit conversion
int intId2 = (int)employee2Id; // 3958
ā Nice debugger display
When you are in a debug mode, you see the values straight away, without digging in.
ā Strongly-typed JSON
Parsing JSON to strong types. Take JSON:
{
"filter": {
"country": "The Netherlands"
}
}
And simply deserialize it:
[StrongType]
public partial class Country
{
}
public class Filter
{
public Country Country { get; init; }
}
// ...
var result = JsonSerializer.Deserialize<Filter>(json);
ā
Strongly-typed appsettings.json
Create appsettings.json
:
{
"ShowsApi": {
"filter": {
"country": "The Netherlands"
}
}
}
Configure DI and inject IOptions<>
:
public class FilterSettings
{
public Country Country { get; set; }
}
[StrongType]
public partial class Country
{
}
// ... Setting up Configuration and DI
public class FilterService
{
// Inject using proper strongly-typed DI
public FilterService(IOptions<FilterSettings> filterSettings)
{
// ...
}
}
Built-in types
Use handy predefined types from Xtz.StronglyTyped.BuiltinTypes
.
š Just use the Xtz.StronglyTyped.BuiltinTypes NuGet package. E-a-s-y:
<PackageReference Include="Xtz.StronglyTyped.BuiltinTypes" Version="0.19.0" />
Example of built-in strong types:
// Built-in types
var country = new Country("Canada");
var jobKey = new JobKey("DX-857");
var currencyCode = new CurrencyCode("EUR");
Types with an extra magic:
// Uses `System.Uri` as an inner type
var uri = new AbsoluteUri("https://example.com");
// Uses `System.Net.IPAddress` as an inner type
var ipAddress = new IpV4Address("127.0.0.1");
// Uses `System.Net.Mail.MailAddress` as an inner type
var email = new Email("john@example.com");
// Just a plain `string` as inner type but with a note of magic
var uppercased = new UpperCased("all-caps strong type"); // "ALL-CAPS STRONG TYPE"
Types with built-in runtime validation (from inner types):
// Uses `System.Uri` as an inner type for validation
var uri1 = new AbsoluteUri("/api");
// ^ System.UriFormatException: 'Invalid URI: The format of the URI could not be determined.'
// Uses `System.Uri` as an inner type for validation
var uri2 = new RelativeUri("https://example.com");
// ^ System.UriFormatException: 'A relative URI cannot be created because the 'uriString' parameter represents an absolute URI.'
// Uses `System.Net.Mail.MailAddress` as an inner type for validation
var email = new Email("incorrect-value");
// ^ System.FormatException: 'The specified string is not in the form required for an e-mail address.'
// Uses `System.Net.IPAddress` as an inner type for validation
var ipAddress = new IpV4Address("999.0.0.1");
// ^ System.FormatException: 'An invalid IP address was specified.'
ā
Auto-generated values for Unit Tests based on AutoData
and Bogus
Use [StrongAutoData]
magic.
š Just use the Xtz.StronglyTyped.BuiltinTypes.AutoFixture NuGet package. E-a-s-y:
<PackageReference Include="Xtz.StronglyTyped.BuiltinTypes.AutoFixture" Version="0.19.0" />
Magical ingredients:
-
AutoFixture
NuGet and[AutoData]
attribute ā to automatically inject data to the test cases -
Bogus
NuGet ā to generate realistic values -
Xtz.StronglyTyped.BuiltinTypes.AutoFixture
NuGet ā to glueAutoFixture
andBogus
together along with strongly-typing the values
Numbers
using Xtz.StronglyTyped.BuiltinTypes.AutoFixture;
using Xtz.StronglyTyped.BuiltinTypes.Numbers;
[Test]
[StrongAutoData]
public void ShouldGenerateStronglyTypedValues(
// Randomly-generated values injected into tests
OddInt32 oddInt32,
NonPositiveInt32 nonPositiveInt32)
{
Console.WriteLine(oddInt32); // 94575 or any other random odd `Int32`
Console.WriteLine(nonPositiveInt32); // -67950 or any other random non-positive `Int32`
}
ā Could you imagine compiler-checked odd numbers?! Here we go!
Automotive types
[Test]
[StrongAutoData]
public void ShouldGenerateStronglyTypedValues(
// Randomly-generated with meaningful values
FuelType fuelType, // Gasoline
VehicleManufacturer vehicleManufacturer, // BMW
VehicleModel vehicleModel, // Model 3
VehicleType vehicleType, // SUV
Vin vin) // B6RUK1QFK8AR56121
{
// ...
}
Names
[Test]
[StrongAutoData]
public void ShouldGenerateStronglyTypedValues(
DisplayName displayName, // Syble Torphy
FirstName firstName, // Odie
LastName lastName, // O'Kon
NamePrefix namePrefix, // Dr.
NameSuffix nameSuffix) // Jr.
{
// ...
}
Conclusion
- Primitive obsession can bring a lot of troubles.
- It prevents benefits of the strongly-typed language.
- Code is less readable nor maintainable.
Using Xtz.StronglyTyped
libraries we can:
- Employ compiler to do the type checks. Never pass the wrong ID or name anymore.
- Use primitive types as inner types (i.e.,
string
,int
). - Use .NET types as inner types (i.e.,
Guid
,Uri
,MailAddress
,IPAddress
, etc.). - Use
IntId
andGuidId
as base classes for our IDs. - Save time with built-in types (i.e.,
Email
,FirstName
,Vin
,Country
,JobKey
, etc.) - Use strong types with extra runtime magic (i.e.,
NonPositiveInt32
,UpperCased
, etc.) - Create own types with custom runtime validation.
- Parse strong types from JSON.
- Use strong types in
appsettings.json
withIOptions<T>
. - Store strong types via Entity Framework.
- Inject randomly-generated strongly-typed values to unit tests.
š Just use the Xtz.StronglyTyped NuGet package. E-a-s-y:
<PackageReference Include="Xtz.StronglyTyped" Version="0.23.0" />
These NuGet packages provide extra help:
Check out
- š©āš» The source code: dev-experience/Xtz.StronglyTyped
- š§ Sample show-case projects dev-experience/Xtz.StronglyTyped.SampleProjects
Feedback is welcome. Feel free to reach out.
Enjoy the types,
Vlad
Posted on January 20, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.