DEV Community

Cover image for STRINGLY-typed obsession
Vlad DX
Vlad DX

Posted on

STRINGLY-typed obsession

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"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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; }
    }
Enter fullscreen mode Exit fullscreen mode

Do you see the problem now?

All values are primitive:

  • Can you tell the difference between Scripted and English?
  • What about Ended and Drama?
  • Some of them can be not string but int. Does it help? What's the difference between ID with value 1 and Runtime with value 60?

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.

Shift left

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:

Auto-complete

💥 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 a float
  • "1.5" is a string

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'
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

✅ 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

Enter fullscreen mode Exit fullscreen mode

✅ Nice debugger display

When you are in a debug mode, you see the values straight away, without digging in.

Debugger display 1

Debugger display 2

✅ Strongly-typed JSON

Parsing JSON to strong types. Take JSON:

{
    "filter": {
        "country": "The Netherlands"
    }
}
Enter fullscreen mode Exit fullscreen mode

And simply deserialize it:

[StrongType]
public partial class Country
{
}

public class Filter
{
    public Country Country { get; init; }
}

// ...

var result = JsonSerializer.Deserialize<Filter>(json);
Enter fullscreen mode Exit fullscreen mode

✅ Strongly-typed appsettings.json

Create appsettings.json:

{
    "ShowsApi": {
        "filter": {
            "country": "The Netherlands"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    {
        // ...
    }
}

Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.'

Enter fullscreen mode Exit fullscreen mode

✅ 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 glue AutoFixture and Bogus 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`
}
Enter fullscreen mode Exit fullscreen mode

❗ 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
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Names

[Test]
[StrongAutoData]
public void ShouldGenerateStronglyTypedValues(
    DisplayName displayName, // Syble Torphy
    FirstName firstName, // Odie
    LastName lastName, // O'Kon
    NamePrefix namePrefix, // Dr.
    NameSuffix nameSuffix) // Jr.
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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 and GuidId 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 with IOptions<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

Feedback is welcome. Feel free to reach out.

Enjoy the types,
Vlad

Top comments (0)