Demystifying Union Types in Dart, Tagged vs. Untagged, Once and For All
Over the years and throughout my career, I have written a lot of code in various languages, such as PHP, Python, Javascript, Typescript, Dart, Java, Go, Clojure, and more. All these languages are equally good and have unique aspects that make you approach problem-solving in a certain way.
One concept seems to appear frequently, often in a rather basic manner but in fact quite complicated: Union Types. Some languages naturally support them; in others, it may seem like an impossible task.
In this article, we will be diving deep into Union Types (Untagged and Tagged Unions)—what they are, why they’re helpful, where they fall short, and why, in strongly typed languages like Dart, you might want to reconsider their use. To convey my message better, I will write examples from different languages, including Typescript, F#, PHP, and more.
Without further ado, let’s get started.
Understanding Shape-based vs. Nominal Typing Languages​
It’s hard to start thinking about unions and other types without understanding the difference between Shape-based and Nominal typing languages. You may know this difference, but let’s be all on the same page.
Shape-based typing refers to a type system that determines the compatibility of types based on their structure rather than their explicit names or tags. It evaluates whether a value conforms to a required "shape" (i.e., the set of properties and their types) to determine if it satisfies a given type. This contrasts nominal typing, where compatibility is determined by explicit type declarations or names (e.g., class inheritance or tagged unions).
Let’s take TypeScript as an example, where types are shape-based (structural). This means that if an object has all the properties required by a type, it is considered to conform to that type, even if it wasn't explicitly declared to do so.
type Point = { x: number; y: number };
const p1: Point = { x: 10, y: 20 }; // Valid: Matches shape
// A function expecting a Point
function logPoint(point: Point) {
console.log(`Point is at (${point.x}, ${point.y})`);
}
// This is valid because the object matches the "shape" of Point
logPoint({ x: 15, y: 30 });
// More complex example
const p2 = { x: 5, y: 10, z: 15 };
logPoint(p2); // Valid: Extra properties (z) are ignored
Here, TypeScript evaluates the shape of p2
to ensure it has x: number
and y: number
. The presence of z: number
does not disqualify p2
because Point
only specifies a subset of properties.
In Dart, the behavior differs from TypeScript's shape-based typing because Dart uses nominal typing. This means an object must explicitly implement a type or class to be compatible with it rather than simply having a matching structure or "shape."
class Point {
Point(this.x, this.y);
final int x;
final int y;
}
void logPoint(Point point) {
print('Point is at (${point.x}, ${point.y})');
}
void main() {
// Valid: Matches the class definition
final p1 = Point(10, 20);
logPoint(p1); // Output: Point is at (10, 20)
}
Here, the Point
class explicitly defines the x
and y
properties. logPoint
expects an instance of Point
. Passing anything else will result in a compile-time error unless converted expressly to a Point
.
Nevertheless, if we want to be fair, Records in Dart are shape-based to some degree. Unlike typical shape-based systems, Records enforce stricter rules; passing additional fields beyond what the record defines will result in a type mismatch.
({int x, int y}) point = (x: 10, y: 20); // A record with two named fields
// Valid: Matches the record's structure exactly
void logPoint({int x, int y}) => print('Point at ($x, $y)');
logPoint(point);
// Invalid: Extra field `z` makes the record incompatible
({int x, int y, int z}) extraPoint = (x: 10, y: 20, z: 30);
// logPoint(extraPoint); // Error: Doesn't match expected structure
While this is shape-based, Dart still provides strong type guarantees and predictable behavior that enforces explicit relationships.
Keep this in mind, and let’s now talk about Union Types.
Understanding Untagged and Tagged Unions​
Sometimes, I see developers talk about their wishlists in one language with a specific name without knowing they might be discussing something different. For example, the untagged and Tagged Union can be in various languages. Both of them are useful in their own way, but we may be shot in the foot without knowing what exactly they are.
Union Types - The Untagged Unions​
Let’s start with Union Types. Union Types allow a value to be one of several specified types, representing a logical "OR" relationship between them. They offer flexibility in defining variables, function arguments, or return types. This lets developers express that a value could have multiple forms while benefiting from type safety.
Union Types are beneficial in scenarios where inputs or outputs can naturally vary, such as handling multiple data formats or designing APIs. Unlike dynamic typing, which sets type interpretation to runtime, Union Types ensures these variations are explicitly defined and checked at compile-time.
Conceptually, a Union Type can be considered the set union of all possible values of its constituent types. In most languages, these unions are defined by |
between each type.
For example, a variable with a type string | int
can take on any value that is either a string
or an int
. This union of types allows for type narrowing, where operations on the variable are restricted based on what is valid for all or some of the union’s members.
Here are a few examples from several languages you might be familiar with. Starting from PHP 8, Union Types became a core feature.
function formatInput(string|int $input): string {
if (is_string($input)) {
return "String: " . $input;
} elseif (is_int($input)) {
return "Number: " . ($input * 10);
}
}
echo formatInput("Hello"); // Output: String: Hello
echo formatInput(5); // Output: Number: 50
Another example is from TypeScript, where the implementation of Union Types uses the |
operator to combine types.
type ID = string | number;
function printId(id: ID): void {
if (typeof id === "string") {
console.log(`Your ID as a string is: ${id.toUpperCase()}`);
} else {
console.log(`Your ID as a number is: ${id * 2}`);
}
}
printId("AB123"); // Output: Your ID as a string is: AB123
printId(42); // Output: Your ID as a number is: 84
At this point, you might wonder: “Can’t inheritance achieve the same thing?”
Inheritance can indeed be used to create a shared base class for types that have related behaviors. For instance, in Dart, we could define an abstract class Input
and extend it for specific cases like integers and strings:
abstract class Input {}
class IntInput extends Input {
IntInput(this.value);
final int value;
}
class StringInput extends Input {
StringInput(this.value);
final String value;
}
void processInput(Input input) {
if (input is IntInput) {
print('Integer: ${input.value}');
} else if (input is StringInput) {
print('String: ${input.value}');
}
}
This approach works when the types are related or when you can introduce a shared base class. However, inheritance quickly becomes cumbersome when dealing with unrelated types.
How do you represent both int
and String
under a common base class without creating artificial constructs? Forcing such a relationship where none naturally exists leads to bloated type hierarchies and unnecessary abstractions.
Union types, on the other hand, excel in handling such cases.
The power of union types becomes even more evident when dealing with shared properties. Imagine two classes, SomeProp
and ElseProp
, both of which have a property called myProp
.
In TypeScript, you can define a union of these two types and directly access the shared property without additional checks:
class SomeProp {
myProp: string[] = [];
}
class ElseProp {
myProp: string[] = [];
}
function fn(value: SomeProp | ElseProp): void {
console.log(value.myProp); // Directly accessible without type checks
}
This capability eliminates the need for a common interface or base class. In scenarios with many unrelated types that share some properties, union types shine by reducing boilerplate and improving readability.
Union types are also invaluable for defining specific sets of values.
For instance, in TypeScript, you can use unions to represent enumerated values dynamically:
type Suit = "hearts" | "diamonds" | "spades" | "clubs";
function describeSuit(suit: Suit): void {
console.log(`You selected the suit: ${suit}`);
}
describeSuit("hearts"); // Valid
// describeSuit("stars"); // Error: Argument is not assignable to type 'Suit'.
In Dart, achieving this requires enums:
enum Suit { hearts, diamonds, spades, clubs }
void describeSuit(Suit suit) {
print('You selected the suit: ${suit.name}');
}
void main() {
describeSuit(Suit.hearts); // Output: You selected the suit: Suit.hearts
}
While Dart enums are type-safe and efficient, they don’t offer the concise flexibility of union types for defining small, ad hoc sets of values.
The examples above represent General or Untagged Unions.
Why Are They Called "Untagged Unions"?​
That is a good question.
The term "Untagged Unions" refers to union types where there is no explicit discriminator or metadata attached to the value to indicate its type. In such systems, a value can belong to one of several types, but the type system does not include any inherent mechanism to differentiate between those types at runtime.
In simpler terms, it is the programmer’s responsibility to perform type checks and ensure correctness.
Look at the TypeScript example above.
function fn(value: SomeProp | ElseProp): void {
console.log(value.myProp); // Directly accessible without type checks
}
In this example, there is no discrimination. You can access the property directly, which is a great example of Untagged Union usage.
However, in other examples above, like this
function printId(id: ID): void {
if (typeof id === "string") {
console.log(`Your ID as a string is: ${id.toUpperCase()}`);
} else {
console.log(`Your ID as a number is: ${id * 2}`);
}
}
You rely on mechanisms like typeof
in TypeScript or is_
functions in PHP to determine what type a value currently holds at runtime.
In TypeScript, the typeof
operator and instanceof
keyword are tools for narrowing union types. While these provide strong compile-time safety, they still rely on manually written type guards to function. TypeScript does not add runtime tags to union types; it relies on the programmer to use the type system effectively. I will discuss this more later in this article, stick with me.
Let’s check another example here.
type Animal = { name: string; sound: string };
type Machine = { name: string; power: number };
type Entity = Animal | Machine;
function describe(entity: Entity) {
if ('sound' in entity) {
console.log(`Animal: ${entity.name} makes sound ${entity.sound}`);
} else if ('power' in entity) {
console.log(`Machine: ${entity.name} has power ${entity.power}`);
}
}
Here, 'sound' in entity
and 'power' in entity
are used as discriminators, but these checks rely on the shape of the object rather than any explicit metadata or "tag" provided by the language.
This sounds good and flexible, but remember that great responsibilities come with great power.
We will go deeper shortly, but since we talked about “tagging,” let’s first see what Tagged Unions are.
Tagged Unions (a.k.a. Sum Types, Discriminated Unions, or Variant Types)​
Tagged Unions, also known as Sum Types, Discriminated Unions, or Variant Types, are a type system feature that represents a value that can take on one of several distinct forms. Unlike untagged unions, Tagged unions include a tag or discriminator field that explicitly identifies the active type, making them safer and easier to use.
A good example of this is, in fact, in F#, a functional programming language. In F#, tagged unions are called Discriminated Unions and are a core feature of the language.
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
let calculateArea shape =
match shape with
| Circle(radius) -> System.Math.PI * radius ** 2.0
| Rectangle(width, height) -> width * height
let circle = Circle(10.0)
printfn "Area: %f" (calculateArea circle) // Output: Area: 314.159...
You may have noticed that each variant (Circle
, Rectangle
) is explicitly tagged. The match statement ensures all cases are handled, and missing cases trigger a compile-time error.
As I mentioned, Tagged unions are called sum types because they combine types into a larger type, much like addition in mathematics. If you imagine every kind as a "set" of possible values, the sum type is the union of those sets, where each value is tagged with its origin.
Let me flashback to the previous example.
type ID = string | number;
function printId(id: ID): void {
if (typeof id === "string") {
console.log(`Your ID as a string is: ${id.toUpperCase()}`);
} else {
console.log(`Your ID as a number is: ${id * 2}`);
}
}
You will see that this untagged union example comes with a method that is "telling apart" or, in other words, considered a "discriminator.” It looks like we are trying to use untagged unions as tagged unions.
if (typeof id === "string") {
} else {
}
Let’s look closely to see what could happen when there is no discriminator or tag and when it is.
When Untagged Union Won’t work​
Many developers love untagged unions as they make their lives relatively easy. Let me be honest: I also love untagged unions, and when I write Typescript, I write untagged unions.
But I have some concerns with it, especially when you write them without knowing what you are doing.
Let me walk you through what I mean and where the issues are, in dynamic-type languages like Typescript or typed systems such as Dart or Java.
Here is our simple use case:
type Input = string | number;
function process(input: Input): string {
if (typeof input === "string") {
return `String: ${input.toUpperCase()}`;
} else {
return `Number: ${input * 10}`;
}
}
Here, Input
is a union of string
and number
. The function uses typeof
to determine which type the value belongs to and processes it accordingly. This approach works fine for simple cases like this. But things become complicated as we scale up.
Overlapping Shapes​
Untagged unions (mostly) rely on structural or shape-based typing, which can lead to confusion when types overlap. This is particularly problematic when different types in the union share common properties but lack a clear way to distinguish between them.
This is mainly a problem when the language doesn’t provide tagged unions, and you have to transform your untagged unions into tagged ones.
Look at the example below:
type Animal = { name: string; sound?: string };
type Machine = { name: string; power?: number };
type Entity = Animal | Machine;
function describe(entity: Entity) {
if ("sound" in entity) {
console.log(`Animal: ${entity.name} makes sound ${entity.sound}`);
} else if ("power" in entity) {
console.log(`Machine: ${entity.name} has power ${entity.power}`);
} else {
console.log(`Unknown Entity: ${entity.name}`);
}
}
describe({ name: "Robot" }); // Ambiguous! Is it an Animal or a Machine?
In this example, the Entity
type is ambiguous because both Animal
and Machine
share the name
property, and without a clear discriminator, the function cannot reliably determine which type it is handling.
Do you see the problem?
Even in such scenarios, for example, in Typescript, we often end up having a Tag.
// Type here is a tag
type Animal = { type: "animal"; name: string; sound: string };
type Machine = { type: "machine"; name: string; power: number };
type Entity = Animal | Machine;
function describe(entity: Entity) {
switch (entity.type) {
case "animal":
console.log(`Animal: ${entity.name} makes sound ${entity.sound}`);
break;
case "machine":
console.log(`Machine: ${entity.name} has power ${entity.power}`);
break;
default:
const exhaustiveCheck: never = entity
throw new Error(`Unhandled case: ${exhaustiveCheck}`)
}
}
describe({ type: "machine", name: "Robot", power: 100 }); // Machine: Robot has power 100
Now, this works because the type
field acts as a tag, which makes the union discriminated and ensures exhaustiveness and clarity in handling cases.
For example, if you add a new variant type
type Plant = { type: "plant"; name: string; growthRate: number };
type Entity = Animal | Machine | Plant;
And if you forget to add it to the switch statement, then TypeScript will now issue a compile-time error:
Property 'growthRate' is missing in type 'Entity'.
With this change, we are getting closer to having tagged unions.
Enjoying this article?
Subscribe to get our latest articles and product updates by email.
Ambiguity in Optional Fields​
There is also another problem. When all fields in the union are optional, untagged unions become impossible to discriminate against.
type A = { payload?: string };
type B = { payload?: number };
type Union = A | B;
function process(input: Union) {
if (typeof input.payload === "string") {
console.log(`String: ${input.payload}`);
} else if (typeof input.payload === "number") {
console.log(`Number: ${input.payload}`);
} else {
console.log("No payload");
}
}
process({}); // Which type is this? Ambiguous!
Here, both A
and B
are valid interpretations of {}
since payload
is optional. The ambiguity makes the union type effectively useless without runtime checks.
Again, this is a problem where untagged unions are not a good candidate to work with, but the possibility exists when running into this issue. That’s where you try to turn an untagged union into a discriminated union.
Flattening and Collapsing Unions​
One of the defining characteristics of untagged union types is their transparency. This transparency often leads to collapsing, where union types simplify into the most basic form, even when nested or redundantly defined.
type AorB = "A" | "B";
type BorA = "B" | "A";
type AOrBOrA = "A" | "B" | "A";
// All of these are equivalent
let value1: AorB = "A";
let value2: BorA = value1;
let value3: AOrBOrA = value2;
Here, the compiler treats all three types as identical because the order of members and duplicates does not change the resulting type.
When unions are nested, they also collapse into their simplest form:
type Optional<T> = T | null;
type Optional2<T> = Optional<Optional<T>>;
type Optional3<T> = Optional<Optional<Optional<T>>>;
// These are equivalent
const opt1: Optional<number> = 42;
const opt2: Optional2<number> = opt1;
const opt3: Optional3<number> = opt2;
Why does this happen? It’s due to the distributive property of union types, where the compiler simplifies nested definitions:
Optional2<T> = (T | null) | null
= T | null
This makes Optional2<T>
identical to Optional<T>
.
The collapsing of unions reflects the transparent nature of untagged union types, where all that matters is the shape of the value and not its structure or hierarchy. While this makes unions computationally efficient and simple to reason about, it can be frustrating when developers need to keep the semantic differences between layers of a union.
Let me elaborate with a few examples:
When types collapse, the original intent of a type may be lost. Look at the example below:
type Optional<T> = T | null; // Represents a single layer of nullability
type UserInput<T> = Optional<T>; // First stage: User-provided value
type DatabaseResult<T> = Optional<UserInput<T>>; // Second stage: Database fetch
type ApiResponse<T> = Optional<DatabaseResult<T>>; // Third stage: API response
At runtime, all these nested types collapse to Optional<T>
because of how union types work:
ApiResponse<T> = Optional<DatabaseResult<T>>
= Optional<Optional<UserInput<T>>>
= T | null | null | null
= T | null
Even though each stage represents a distinct source of nullability, the final type does not retain this information. See my code below:
function fetchData(): ApiResponse<string> {
// Simulating nullability at various stages
const userInput: UserInput<string> = null; // User didn't provide input
const dbResult: DatabaseResult<string> = userInput; // Nothing in the database
const apiResponse: ApiResponse<string> = dbResult; // API also returns nothing
return apiResponse;
}
function processData(value: ApiResponse<string>) {
if (value === null) {
console.log("No data available at any stage.");
} else {
console.log(`Final value: ${value}`);
}
}
// Example usage
const result = fetchData();
processData(result);
This lack of clarity is the problem of collapsing in this scenario. The nuances of where null originated are lost. This can lead to confusion if you intend to represent distinct levels of meaning.
In some cases, the collapsing behavior might lead to unexpected simplifications that alter type behavior in quiet ways. For example:
type APIResponse<T> = T | { error: string }; // A successful result (T) or an error
type NestedAPIResponse<T> = APIResponse<APIResponse<T>>; // A nested response
But when the type collapses:
NestedAPIResponse<T> = T | { error: string } | { error: string }
= T | { error: string }
In this case, the type system no longer reflects the original structure of the API, where responses were supposed to include both outer errors
(e.g., network failures) and inner errors
(e.g., validation errors). Look at example below which you might even have written it before:
type BookDetails = { id: string; title: string };
type BookReview = { rating: number; comment: string };
type BookAPIResponse = APIResponse<BookDetails>;
type ReviewAPIResponse = APIResponse<BookReview>;
type CombinedResponse = NestedAPIResponse<BookReview>;
// Fetch book details and reviews
const fetchBookData = async (): CombinedResponse => {
const bookResponse: BookAPIResponse = { id: "123", title: "Dart Simplified" };
if ("error" in bookResponse) {
// Look at HERE
return { error: "Failed to fetch book details" };
}
// Look at HERE
const reviewResponse: ReviewAPIResponse = { error: "No reviews found" };
return reviewResponse;
};
const handleData = async () => {
const response = await fetchBookData();
if (typeof response === "string") {
console.log(`Book and reviews fetched: ${response}`);
// Look at HERE
} else if ("error" in response) {
console.log(`Error: ${response.error}`);
}
};
handleData();
When the response is { error: "No reviews found" }
, you cannot determine whether the book database failed or the review service failed. You must treat all errors as generic { error: string }
, which limits the ability to provide precise error messages or recovery strategies.
Don’t get me wrong, collapsing is not necessarily bad.
In most cases, collapsing simplifies the type system, reducing redundancy and improving type-checking performance. However, developers should be aware of this behavior and plan accordingly, especially in complex type hierarchies or nested unions, where collapsing might obscure the intended semantics of the type.
Now, guess how we can fix the latter issue? Yes, correct, by adding a Tag.
type OuterError = { type: "outer"; message: string };
type InnerError = { type: "inner"; message: string };
type APIResponse<T> = T | InnerError;
type NestedAPIResponse<T> = APIResponse<T | OuterError>;
Now, you can handle errors more precisely:
const handleResponse = (response: NestedAPIResponse<string>) => {
if (typeof response === "string") {
console.log(`Success: ${response}`);
} else if (response.type === "outer") {
console.log(`Outer API Error: ${response.message}`);
} else if (response.type === "inner") {
console.log(`Inner API Error: ${response.message}`);
}
};
That’s what I mean when I say being aware of what you seek.
Inferring Ambiguous Types​
One challenge with untagged union types is how they behave during type inference, particularly with generics and conditional logic. The type of a value may seem straightforward, but it can be undecidable due to how compilers interpret unions in combination with generics.
Take this TypeScript example:
type DashOrSparky = "dash" | "sparky";
declare function needDash(v: "dash"): void;
function handleDashOrSparky<T extends DashOrSparky>(v1: T, v2: T): void {
if (v1 === "dash") {
needDash(v1); // OK: v1 is narrowed to "dash"
}
if (v1 === "dash") {
needDash(v2); // Error: Argument of type 'DashOrSparky' is not assignable to parameter of type '"dash"'.
}
}
Why Does This Error Happen? Let me break this down so that you can understand where the problem is. The issue lies in two different areas:
- Type Narrowing Is Local:
- Inside the first
if
statement,v1
is correctly narrowed to"dash"
, so the call toneedDash(v1)
succeeds. - However, the type of
v2
remainsDashOrSparky
becausev1
andv2
are treated as independent variables. Their relationship is defined only by the generic typeT
, which remains ambiguous.
- Inside the first
- Generics and Union Types:
- The generic type
T
extendsDashOrSparky
, meaning it can represent"dash"
,"sparky"
, or their union"dash" | "sparky"
. - When
T
is"dash" | "sparky"
,v1
andv2
can both be of typeT
, but their specific runtime values do not have to match:v1
could be"dash"
.v2
could be"sparky"
.
- The generic type
As a result, even though v1
is narrowed to "dash"
inside the first if
, the compiler cannot assume that v2
also holds "dash"
. It must consider the possibility that v2
is "sparky"
, leading to the error.
In this particular example, TypeScript’s type system ensures type safety by requiring explicit handling of all possible scenarios. This results in compile-time errors when assumptions about type relationships cannot be guaranteed. This strict behavior avoids subtle runtime bugs and helps programmers to be responsible for explicitly handling such cases.
However, this problem isn’t unique to union types. A similar issue arises in Dart, even without union types, due to its handling of generics and type inference.
class Dash {}
void needDash(Dash d) {}
void handleDashOrSparky<T extends Object>(T v1, T v2) {
if (v1 is Dash) {
needDash(v1); // OK: v1 is correctly cast to Dash
}
if (v1 is Dash) {
needDash(v2); // Error: v2 is still treated as T, not Dash
}
}
What’s happening here in Dart? Let me break it down to understand better:
- Type Narrowing Is Localized:
- When Dart narrows
v1
toDash
within theif
block, it allowsneedDash(v1)
to succeed. - However, Dart retains the original generic type
T
forv2
, as there is no connection between the runtime type ofv1
and the static type ofv2
. This results inv2
remainingT
, unaffected by the narrowing applied tov1
.
- When Dart narrows
- Lack of Cross-Variable Inference:
- Dart’s type system does not infer that if
v1
is confirmed to beDash
, thenv2
—sharing the same generic typeT
—must also beDash
. This lack of contextual inference results in a compile-time error when attempting to usev2
as aDash
.
- Dart’s type system does not infer that if
In Dart, even though it doesn’t support union types, it similarly fails to propagate type narrowing across multiple generic variables. This can lead to frustration, as developers must manually ensure type safety, even in cases where a logical relationship exists between variables.
For example, this workaround works but is unsafe:
void handleDashOrSparkySafe<T extends Object>(T v1, T v2) {
if (v1 is Dash) {
needDash(v1); // OK
needDash(v2 as Dash); // Assumes v2 is also Dash; unsafe if v2 is not Dash
}
}
Alright, I think you have seen the proper usage of untagged unions, where they don’t work, their challenges when to use tagged unions, and even how sometimes you transform an untagged union into a discriminated union.
Let’s explore how we can use tagged and untagged unions in Dart.
Enjoying this article?
Subscribe to get our latest articles and product updates by email.
Exploring Untagged and Tagged Unions in Dart​
Before I start, let me mention that there is an open issue on the Dart Language repository, where many interesting inputs have been written since 2018.
Because of many of those reasons we have explored, Dart does not currently have a native way to define union types (e.g., A | B
)., but Dart has not left without any tool.
Dart does provide two structural union-type constructs that achieve similar functionality in specific use cases:
FutureOr
: A Union of Future<T>
and T
​
According to this post, the use of FutureOr
, introduced with Dart 2, allows you to provide either a value or a future at a point where the existing Dart 1 API allowed the same thing for convenience, only in a way that can be statically typed.
The FutureOr
type is commonly used in APIs with synchronous and asynchronous code. Instead of requiring developers to wrap synchronous values in a Future
, FutureOr
lets them provide values directly.
Here is an example
import 'dart:async';
FutureOr<String> fetchData(bool asyncMode) {
if (asyncMode) {
return Future.value('Async data');
} else {
return 'Sync data';
}
}
As you can see, the challenge here is that you must often perform runtime-type checks to handle each case. (result is Future<String>
)
Wait, there is also another issue with this type. Awaiting FutureOr
without checking if the returned value is a Future results in an unnecessary async gap, just imagine the below code
FutureOr<String> fetchData(bool asyncMode) {}
void main() async {
await fetchData(true);
}
In fact, Slava Egorov, manager at Dart Team, thinks this API has even been a mistake.
But no worries—DCM offers a lint rule to prevent such an error from occurring in this particular use case.
The prefer-unwrapping-future-or warns when a FutureOr is not unwrapped before being used.
So, the example above should be handled as below:
import 'dart:async';
FutureOr<String> fetchData(bool asyncMode) {
if (asyncMode) {
return Future.value('Async data');
} else {
return 'Sync data';
}
}
void main() async {
FutureOr<String> result = fetchData(true);
if (result is Future<String>) {
result = await result; // Await if it's a Future
}
print(result); // Output: Async data
}
You can ensure that the error does not happen again. Even though this union type exists, it still has potential issues.
DCM is a code quality tool that helps your team move faster by reducing the time spent on code reviews, finding tricky bugs, identifying complex code, and unifying code style.
_?
: Nullable Types (T?
), which act as a union of T
and Null
​
Nullable types in Dart (denoted by T?
) act as a union between T
and Null
types.T?
is equivalent to a union type T | Null
.
String? processInput(bool includeNull) {
if (includeNull) {
return null; // Null case
} else {
return "Hello, Dart!"; // Non-null case
}
}
void main() {
String? value = processInput(true);
if (value != null) {
print(value.toUpperCase()); // Safe access
} else {
print("Value is null");
}
}
These are only two options that add complexity, especially for run-time checks. Now, let’s consider other complexities that may arise.
What Can Go Wrong With Imaginary Untagged Unions in Dart​
Using untagged unions can significantly simplify the code, such as SomeProp
, which we have reviewed and potentially can be a good addition to Dart.
However, using untagged union types in scenarios with a natural mapping between types can lead to redundancy, generally longer code, and more difficulty in reading. This problem is particularly relevant in languages or frameworks where union types are not natively supported or type safety is paramount.
Inspired by this great example by Slava, let me explain what I mean.
In Flutter, many developers are tempted to write functions that accept double | EdgeInsetsGeometry
as a parameter. The goal is to allow passing either a double
(which will then be converted to an EdgeInsetsGeometry
internally) or an EdgeInsetsGeometry
directly, thus making the caller’s code look shorter.
Hypothetically, the example will look like this.
void setPadding(double | EdgeInsetsGeometry padding) {
if (padding is double) {
padding = EdgeInsets.all(padding);
}
// Use the padding as EdgeInsetsGeometry
print(padding);
}
The idea here is to simplify the caller's code, allowing:
setPadding(10); // Implicitly converts to EdgeInsets.all(10)
setPadding(EdgeInsets.symmetric(vertical: 10, horizontal: 20)); // Already EdgeInsetsGeometry
First thing first, this is redundant.
There is already a clear and concise way to represent padding in Flutter using EdgeInsetsGeometry
. Adding support for double
as a parameter type introduces unnecessary complexity.
void setPadding(EdgeInsetsGeometry padding) {
// Use padding directly
print(padding);
}
// Caller handles conversion
setPadding(EdgeInsets.all(10));
setPadding(EdgeInsets.symmetric(vertical: 10, horizontal: 20));
Internally converting double
to EdgeInsetsGeometry
using EdgeInsets.all
introduces runtime type checks and conditional logic.
void setPadding(double | EdgeInsetsGeometry padding) {
if (padding is double) {
padding = EdgeInsets.all(padding);
} else if (padding is! EdgeInsetsGeometry) {
throw ArgumentError('Invalid padding type');
}
print(padding);
}
As discussed in this article, one of the common issues with untagged unions in languages without exhaustive pattern matching (like Dart, which lacks built-in untagged union type) cannot ensure that all possible cases are handled at compile time. For example, If a new type is added to the union, existing code won’t automatically detect the need to handle it:
void setPadding(double | EdgeInsetsGeometry | int padding) {
// Original code won't handle `int`, leading to runtime errors
}
A better approach is to avoid union types altogether and enforce explicit type conversion by the caller. This keeps the function’s signature simple, unambiguous, and easy to maintain.
Here is how we can improve the example above
void setPadding(EdgeInsetsGeometry padding) {
print('Padding: $padding');
}
// Caller handles conversion explicitly
void main() {
setPadding(EdgeInsets.all(10)); // Explicit conversion
setPadding(EdgeInsets.symmetric(vertical: 10, horizontal: 20)); // Already valid
}
You will see that this example does not properly use untagged unions and forces us to use some discrimination or tagging.
That’s where there is another way to do this, which could be to have a sealed class in Dart 3.
Tagged Union Types in Dart Using Sealed Class​
Sealed classes allow us to define a closed set of subtypes, ensuring exhaustive handling in code. This is particularly useful for scenarios like the example above, where the input could conceptually represent multiple types (e.g., double
or EdgeInsetsGeometry
).
Let’s refactor the example to use a sealed class hierarchy.
sealed class Padding {}
final class PaddingAll extends Padding {
PaddingAll(this.value);
final double value;
}
final class PaddingGeometry extends Padding {
PaddingGeometry(this.value);
final EdgeInsetsGeometry value;
}
The function accepts Padding
as the parameter and uses pattern matching or type checks to handle the different cases.
import 'package:flutter/widgets.dart';
void setPadding(Padding padding) {
if (padding is PaddingAll) {
print(EdgeInsets.all(padding.value)); // Convert double to EdgeInsets.all
} else if (padding is PaddingGeometry) {
print(padding.value); // Already EdgeInsetsGeometry
} else {
throw ArgumentError('Unsupported padding type');
}
}
Call the setPadding
function with either a PaddingAll
or a PaddingGeometry
instance:
void main() {
setPadding(PaddingAll(10)); // Converts to EdgeInsets.all
setPadding(PaddingGeometry(EdgeInsets.symmetric(vertical: 10, horizontal: 20))); // Uses EdgeInsetsGeometry directly
}
However, this needs to be adequately implemented. Let me explain.
With Dart’s recent support for pattern matching, handling sealed classes becomes even simpler and more elegant and ensures the exhaustiveness check, which is a proper way to use if
.
void setPadding(Padding padding) {
switch (padding) {
case PaddingAll(value: var value):
print(EdgeInsets.all(value)); // Convert double to EdgeInsets.all
break;
case PaddingGeometry(value: var value):
print(value); // Already EdgeInsetsGeometry
break;
}
}
This removes the need for manual type checks and makes the function easier to maintain.
It’s good to mention that while sealed classes are great, there are some trade-offs with them including:
- Sealed classes must be explicitly defined, typically within your codebase or as part of a shared package. This means the type definition needs to be maintained, documented, and often carried around wherever the type is used.
- If the sealed class is part of a package API, it becomes exposed to consumers, potentially increasing the complexity of the API. This requires careful design to ensure the abstraction remains clean and intuitive.
One way to mitigate this is to use an abstract wrapper class with generics to reduce boilerplate.
sealed class Union<T1, T2> {
const Union();
}
class Left<T1, T2> extends Union<T1, T2> {
const Left(this.value);
final T1 value;
}
class Right<T1, T2> extends Union<T1, T2> {
const Right(this.value);
final T2 value;
}
void setPadding(Union<double, EdgeInsetsGeometry> padding) {
switch (padding) {
case Left<double, EdgeInsetsGeometry> left:
print(EdgeInsets.all(left.value)); // Convert double to EdgeInsets
break;
case Right<double, EdgeInsetsGeometry> right:
print(right.value); // Already EdgeInsetsGeometry
break;
}
}
void main() {
// Using the generic union wrapper
final padding1 = Left<double, EdgeInsetsGeometry>(10.0);
final padding2 = Right<double, EdgeInsetsGeometry>(EdgeInsets.all(20.0));
setPadding(padding1); // Output: EdgeInsets.all(10.0)
setPadding(padding2); // Output: EdgeInsets.all(20.0)
}
However, this introduces its own challenges, such as additional complexity in design and usability or needing an abstract wrapper for different number of subclasses.
Keep in mind that when it comes to sealed classes, the analyzer warns you when a switch case does not cover a new class. This feature is unavailable for if statements and conditional expressions, though I don’t recommend using if
. Therefore, using switches where possible can reduce the number of potential bugs.
That is where you can use Lint tools such as DCM to help you warn when a switch is not used. The prefer-switch-with-sealed-classes
rule is mainly for this reason: it suggests using a switch statement or expression instead of conditionals with sealed class instances.
DCM is a code quality tool that helps your team move faster by reducing the time spent on code reviews, finding tricky bugs, identifying complex code, and unifying code style.
Using the Either
Class for Tagged Unions​
Another approach to simulate the Tagged Union in Dart is the Either
class. You may have encountered Either
commonly in functional programming libraries such as dartz
or either_dart
.
Either
typically represents a disjoint union between two types: a Left
(often used for errors or alternative cases) and a Right
(normally used for successful results).
This approach introduces explicit tagging to distinguish between the two types at runtime, providing clarity and compile-time safety. Let’s take a look at an example using either_dart
package.
import 'package:either_dart/either.dart';
// Simulating a result type
Either<String, int> divide(int a, int b) {
if (b == 0) {
return Left("Division by zero error");
} else {
return Right(a ~/ b);
}
}
void handleResult(Either<String, int> result) {
result.fold(
(error) => print("Error: $error"), // Handle the Left case
(value) => print("Result: $value"), // Handle the Right case
);
}
void main() {
final success = divide(10, 2);
handleResult(success); // Output: Result: 5
final failure = divide(10, 0);
handleResult(failure); // Output: Error: Division by zero error
}
The Either
class inherently provides tags: Left
and Right
. These tags ensure that the value being handled is always unambiguous, similar to the type
field in a tagged union. The .fold()
method enforces exhaustive handling of both cases (Left
and Right
). This ensures no scenario is overlooked.
Let me mention that in functional programming, Either
is a monad, which allows chaining and transformations while preserving type safety and clarity.
Using packages can simplify using Either
; however, creating that in Dart is straightforward, so let’s do it together.
sealed class Either<L, R> {
const Either();
/// Apply a function based on whether the value is `Left` or `Right`
T fold<T>(T Function(L left) fnL, T Function(R right) fnR);
}
class Left<L, R> extends Either<L, R> {
final L value;
const Left(this.value);
T fold<T>(T Function(L left) fnL, T Function(R right) fnR) => fnL(value);
}
class Right<L, R> extends Either<L, R> {
final R value;
const Right(this.value);
T fold<T>(T Function(L left) fnL, T Function(R right) fnR) => fnR(value);
}
Tags (Left and Right) ensure clear discrimination between the two possible values. They allow for explicit handling of each case, which ensures clarity and compile-time safety.
While the fold
method provides a functional way to handle both cases, a switch
statement can be used to enforce exhaustiveness, where it makes sure all possibilities are handled explicitly. For example, you can define a method as below:
void handleResult<Either<L, R>>(Either<String, int> result) {
switch (result) {
case Left(:final value):
print("Error: $value");
break;
case Right(:final value):
print("Success: $value");
break;
}
}
And then, using this method, you can ensure both Left and Right are handled.
void main() {
final Either<String, int> success = Right(42);
final Either<String, int> failure = Left("An error occurred");
handleResult(success); // Output: Success: 42
handleResult(failure); // Output: Error: An error occurred
}
Notice the case
statements use Dart’s pattern-matching syntax (:final value
) to extract the values from Left
and Right
. This makes this function Dart-3-friendly.
Extension Type Unions Package​
The extension_type_unions package offers a workaround by leveraging extension types to simulate union types in Dart.
The extension_type_unions
package provides a way to define union types using generic classes named Union2
, Union3
, and Union9
, corresponding to unions of two to nine types, respectively.
For example, you would use Union2<int, String>
to represent a union of int
and String
.
import 'package:extension_type_unions/extension_type_unions.dart';
int processValue(Union2<int, String> value) => value.split(
(int i) => i + 1,
(String s) => s.length,
);
void main() {
print(processValue(1.u21)); // Outputs: 2
print(processValue('Hello'.u22)); // Outputs: 5
}
The package implements untagged unions, meaning no runtime wrapper object indicates which type the value holds. The type of information is managed at compile-time.
Also, keep in mind the union types are covariant; for example, Union2<int, Never>
is a subtype of Union2<num, Object>
, assuming int
is a subtype of num
.
Conclusion​
In conclusion, while Dart does not natively support untagged union types, this might be intentional, and for good reason. Even though having Untagged Unions for some scenarios can significantly improve readability and simplify writing code, using them inappropriately introduces ambiguity and complexity, especially when dealing with overlapping or unrelated types.
In such scenarios, where you require manual discrimination, which can lead to harder-to-read and error-prone code, you may want to use Tagged Unions.
Dart offers sealed classes, which are type-safe alternatives for scenarios where tagged unions are beneficial. You can also leverage Either
where tags (Left and Right) discriminate properly.
If you still would like to use untagged unions (for a simpler use case that makes sense, for example, Web APIs), tools like the extension_type_unions
package offer creative workarounds for flexible type handling. However, these solutions come with limitations, such as the lack of exhaustiveness checking and potential type safety issues.