🏷️ Tagged: A Newtype Pattern, a Safe & Type-Restricted Wrapper for Dart and Flutter

Oleksandr Prokhorenko
5 min readJul 18, 2023
Tagged: A Newtype Pattern, a Safe & Type-Restricted Wrapper for Dart.
https://github.com/minikin/tagged

Motivation

Frequently, we deal with data types that are overly broad or encompass more values than required for our particular field.
Sometimes, we may need to distinguish between two values that appear equivalent at the type level.

An email address, for instance, is merely a String, yet it needs certain limitations on its usage. Similarly, while a User id can be represented using a String, it should be distinguishable from a Subscription id using a String as its base.

The Tagged pub is handy in averting severe runtime errors during the compile time by effortlessly encapsulating fundamental types within more distinct contexts.

The problem

Dart has a robust and versatile type system, but it’s still common to model most data like this:

class User{
final String id;
final String address;
final String email;
final String? subscriptionId;

const User(
this.subscriptionId, {
required this.id,
required this.address,
required this.email,
});
}


class Subscription {
final String id;

const Subscription({
required this.id,
});
}

We are utilizing the same data type for both user and subscription IDs in our model. However, it is crucial to note that our application logic should not consider these values interchangeably. To illustrate, let’s consider a scenario where we need to create a function to retrieve a subscription:

Subscription getSubscription(String id) =>
subscriptions.firstWhere((element) => element.id == id);

This type of code is extremely common, but it can lead to significant runtime bugs and security vulnerabilities. Although it may compile, run, and appear reasonable at first glance, it must be aware of its potential risks.

final subscription = getSubscription(user.id);

This code is prone to failure when locating a user’s subscription. Even worse, if there is an overlap between user IDs and subscription IDs, it will incorrectly display subscriptions to the wrong users. This can lead to severe consequences, including the exposure of sensitive information such as billing details.

The solution

Using the Tagged allows us to differentiate between types succinctly.

typedef UserId = Tagged<User, String>;

class User {
final UserId id;
final String address;
final String email;
final SubscriptionId? subscriptionId;

const User(
this.subscriptionId, {
required this.id,
required this.address,
required this.email,
});
}

typedef SubscriptionId = Tagged<Subscription, String>;

class Subscription {
final SubscriptionId id;

const Subscription({
required this.id,
});
}

In this scenario, we have utilized the container type to uniquely tag each ID, thereby implementing the Tagged approach. We can effectively differentiate between user and subscription IDs by incorporating a generic tag parameter within the container type. This enables us to maintain distinct types and ensures that our code accurately handles each ID, mitigating the risk of errors and bolstering security by safeguarding sensitive information like billing details.

We can now update getSubscription to take a SubscriptionId where it previously took any String.

Subscription getSubscription(SubscriptionId id) =>
subscriptions.firstWhere((element) => element.id == id);

This further enhances the reliability and correctness of our code, reducing the risk of runtime errors and ensuring that the appropriate ID is used in the intended context.

final subscription = getSubscription(user.id); // ❌ Error

❌ The argument type ‘Tagged<User, String>’ can’t be assigned to the parameter type ‘Tagged<Subscription, String>’.

Handling Tag Collisions

Another bug remains unresolved within the types we’ve defined. We have implemented a function with the following signature:

void sendEmail(String userAddress) {}

It lacks proper input validation and accepts any string, which poses a potential issue.

sendEmail(user.address)

Although the code compiles and runs, its logic has a critical flaw. The variable user.address is mistakenly assumed to refer to the user’s email address, when it points to their billing address. As a result, the emails intended for users are not being sent. Additionally, if the function is called with invalid data, it may lead to server churn and crashes, exacerbating the issue further. This issue must be addressed to ensure that users receive the appropriate communications and prevent potential server disruptions.

Let’s try to fix the issue with Tagged:

typedef UserId = Tagged<User, String>;

typedef Email = Tagged<User, String>;

typedef Address = Tagged<User, String>;

class User {
final UserId id;
final String address;
final Email email;
final SubscriptionId? subscriptionId;

const User(
this.subscriptionId, {
required this.id,
required this.address,
required this.email,
});
}

However, we shouldn’t reuse Tagged<User, String> for Email and Address because the compiler would treat UserId, Email and Address as the same type! We need a new tag, which means we need a new type. We can use any type, but an uninhabited abstract class is uninstantiable, which is perfect here.

typedef UserId = Tagged<User, String>;

abstract class EmailTag {}
typedef Email = Tagged<EmailTag, String>;

abstract class AddressTag {}
typedef Address = Tagged<AddressTag, String>;

class User {
final UserId id;
final Address address;
final Email email;
final SubscriptionId? subscriptionId;

const User(
this.subscriptionId, {
required this.id,
required this.address,
required this.email,
});
}

Now we can update sendEmail to take an Email where it previously took any String.

void sendEmail(Email email) {}
sendEmail(user.address) // ❌ Error

❌ The argument type ‘Tagged<AddressTag, String>’ can’t be assigned to the parameter type ‘Tagged<EmailTag, String>’.

We’ve now distinguished Email and Address at the cost of an extra line per type, things are explicitly documented.

Records labels in the type system can differentiate seemingly equivalent record types, allowing us to save an additional line of code.

typedef UserId = Tagged<User, String>;

typedef Email = Tagged<({User user, String email}), String>;

typedef Address = Tagged<({User user, String address}), String>;

class User {
final UserId id;
final Address address;
final Email email;
final SubscriptionId? subscriptionId;

const User(
this.subscriptionId, {
required this.id,
required this.address,
required this.email,
});
}

If we accidentally swap the arguments, the compiler will catch it.

final user = const User(
null,
id: UserId('1'),
address: Address('address'),
email: Address('email@email.com'), // ❌ Error
);

❌ The argument type ‘Tagged<({String address, User user}), String>’ can’t be assigned to the parameter type ‘Tagged<({String email, User user}), String>’.

Accessing Raw Values

Tagged expose its raw values via a rawValue property:

final id = user.id.rawValue; // String

Why not use Typedefs?

Typedefs are just that: aliases. A type alias can be used interchangeably with the original type and offers no additional safety or guarantees.

Tagged on GitHub

Tagged on Pub.dev

--

--