š·ļø Tagged: A Newtype Pattern, a Safe & Type-Restricted Wrapper for Dart and Flutter

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.