Cross-Boundary ADTs
Modeling a domain with algebraic data types (ADTs) is a powerful, expressive way to design code that is both concise and readable. Too often, code that does too little with too many lines negatively impacts readability and maintainability. ADTs, when supported by a language, provide a way to do more with less, allowing us to capture complex domain logic succinctly and clearly.
However, when software crosses boundaries, it often also changes languages. In such scenarios, we tend to fall back on concepts like Data Transfer Objects (DTOs) because of our familiarity with class-driven development, rather than embracing type-driven development. In a class-driven paradigm, DTOs serve as simple containers for transmitting data without behavior. But when we have the luxury of using a language that supports ADTs - and when these ADTs are serializable - there’s no inherent need to define pure "transport" types. Instead, we can serialize and transfer the ADT directly, preserving its structure and intent, as long as the receiving language is compatible.
To explore this, imagine we have an F# backend, with TypeScript and Flutter (Dart) frontends. Let’s design a quick domain using ADTs and see how these can seamlessly cross boundaries, utilizing the strengths of each language—without compromising the type systems of any of them, and while keeping the domain a pure representation of the business.
Designing the Domain
We'll start with creating our domain in F#. Suppose we're working on a domain for a subscription service. At the core of this domain, we might have an ADT (in F# this type is called a discriminated union
) that represents different types of subscriptions:
type TrialTier =
{ RemainingTrialDays: int }
type BasicTier =
{ Price: double }
type PremiumTier =
{ Price: double
Features: string list }
type Subscription =
| Trial of TrialTier
| Basic of BasicTier
| Premium of PremiumTier
Here, we've defined a Subscription
type that can be one of three variants: Trial
, Basic
, or Premium
. Each variant can carry additional data - Trial
carries the amount of remaining days in the trial, Basic
has a price
, and Premium
has both a price
and a list of additional features
.
This ADT succinctly captures the different kinds of subscriptions our service offers, along with the associated data, all within a single type. It's concise, easy to understand, and expressive.
Benefits in F#
At this point, it's important to highlight the unique benefits of modeling your domain with ADTs in F#. For those accustomed to a class-driven approach, the power of ADTs may not be immediately apparent, but their brevity and simplicity offer significant advantages that can be easily overlooked.
- Clarity and Domain Representation: Designing types with ADTs provides a clear and concise representation of your domain. The absence of unnecessary syntax and the elimination of complex class hierarchies make the domain’s structure easy to grasp at a glance. This clarity extends beyond developers to domain experts, who can understand the business logic with minimal programming knowledge. The focus remains on what matters most: accurately modeling the domain.
- Compiler-Level Enforcement: One of the standout features of using ADTs in F# is the compiler’s ability to enforce exhaustive pattern matching. When you define a type like
Subscription
, the F# compiler ensures that every time this type is used, all possible cases are accounted for. If a case is missed, the compiler warns you, preventing potential runtime errors. This level of enforcement becomes especially valuable as your domain evolves, providing a safeguard against introducing bugs when adding new variants to your types. - Expressiveness and Simplicity: F# allows you to express complex ideas in a simple, direct way. ADTs are particularly powerful in this regard. They enable you to encapsulate sophisticated business logic within concise type definitions, making your code both expressive and readable. This expressiveness leads to more intuitive and maintainable code, where the intent is clear, and the logic is straightforward.
- Eliminating Boilerplate Code: In traditional class-based designs, you would need to manually implement constructors, getters, setters, and equality checks for each class. With ADTs, F# handles all of this automatically, significantly reducing the amount of boilerplate code you need to write. This not only speeds up development but also reduces the likelihood of introducing errors related to these repetitive tasks.
- Pattern Matching Integration: F#’s pattern matching is tightly integrated with ADTs, making it easy to deconstruct and work with your types. This integration allows for concise and powerful handling of different cases, further reducing the complexity of your code. Pattern matching combined with ADTs provides a natural and efficient way to express domain logic, leading to cleaner and more maintainable code.
What About Classes? To be sure, F#, like TypeScript and Dart, supports designing these types using classes instead of ADTs. However, this approach would miss out on many of the benefits we've discussed. With classes, you'd lose the compiler’s ability to enforce exhaustive pattern matching, which is crucial for maintaining type safety as your domain grows. You would also reintroduce the need for boilerplate code and potentially obscure the domain's clarity with unnecessary object-oriented structures. By opting for ADTs, you embrace a more functional and declarative style that aligns better with F#'s strengths, leading to more robust and maintainable code.
As we can hopefully see, modeling the domain with ADTs is beneficial in F# and I hope to show that this benefit extends to other languages across boundaries. So let's keep going.
Serialization for Transfer
Once we've designed our domain using ADTs in F#, the next step is to consider how we can transfer this structured data to our frontends - in this case, TypeScript and Dart. Unlike modeling domains in stateful classes, ADTs are pretty easily serializable, making it straightforward to convert these types into a format that can be consumed by other languages.
In F#, serialization to JSON is quite simple. For example, if we have an instance of our Subscription
type:
let premiumSubscription =
Premium { Price = 29.99
Features = ["Ad-free", "Offline access"] }
We can serialize this to JSON using a library like Microsoft.FSharpLu.Json
.
let json = Compact.Serialize(premiumSubscription)
The resulting JSON looks like this:
{
"Premium": {
"Price": 29.99,
"Features": ["Ad-free", "Offline access"]
}
}
This JSON structure is universal and can be easily consumed by our TypeScript and Dart frontends.
TypeScript Integration
On the TypeScript side, we can define a type that matches the F# ADT structure:
type Subscription =
| { kind: "Trial", remainingTrialDays: number }
| { kind: "Basic", price: number }
| { kind: "Premium", price: number, features: string[] };
Using a discriminated union, TypeScript allows us to represent the same concept we defined in F#. When the JSON is received on the frontend, it can be parsed and used directly, preserving the integrity of the type system.
In materializing the data from the API call, we can convert the response JSON structure to a TypeScript ADT (like in F#, this is called a discriminated union in TypesScript).
function mapJsonToSubscription(json: any): Subscription {
if (json.Trial) {
return {
kind: "Trial",
remainingTrialDays: json.Trial.RemainingTrialDays
};
} else if (json.Basic) {
return {
kind: "Basic",
price: json.Basic.Price
};
} else if (json.Premium) {
return {
kind: "Premium",
price: json.Premium.Price,
features: json.Premium.Features
};
} else {
throw new Error("Unknown subscription type");
}
}
This function checks for the presence of the Trial
, Basic
, or Premium
keys in the JSON and then maps them to the corresponding TypeScript structure. This way, the JSON from F# is correctly transformed into a TypeScript-friendly format with the required discriminator, in our case defined as kind
.
Now that it is materialized, our TypeScript code will be able to handle Subscription
anywhere in the code and will be able to access members in a type-safe way depending on the kind.
Benefits in TypeScript
TypeScript’s discriminated unions allow you to capture the essence of algebraic data types (ADTs) in a way that significantly enhances both the brevity and safety of your code, particularly in the context of web development.
- Brevity and Clarity: Discriminated unions in TypeScript enable you to express complex data structures with minimal code. This leads to more concise and readable code, reducing the amount of boilerplate you would otherwise need with traditional class-based or object-oriented approaches. For example, instead of defining multiple classes with shared properties, you can define a single union type that captures all possible states of your data. This not only simplifies your code but also makes the domain model easier to understand and maintain.
- Safe Access to Type Members: One of the key advantages of using discriminated unions is the ability to safely access members of a type without needing to resort to casting or runtime checks. When working with web components or other parts of a TypeScript application, you can leverage the
kind
ortag
property of a union type to safely narrow down the type and access specific members. Thus having a single web component for aSubscription
card on the user's profile has a clean implementation even though there's three possibilities to the singleSubscription
type that's passed in. - Simplified Conditional Logic: When working with discriminated unions, TypeScript’s control flow analysis helps you manage conditional logic in a way that is both simple and error-resistant. The language’s ability to narrow types based on conditional checks means that you can avoid repetitive and error-prone type assertions. Instead, TypeScript provides strong guarantees that the members you access are valid for the current state of the data, making your code more reliable and easier to reason about.
- Type Safety Across the Stack: When using TypeScript’s discriminated unions, you can maintain strong type safety across your entire application stack. This is particularly beneficial in larger codebases or when working in teams, as it ensures that changes to the domain model are consistently propagated throughout the application. Developers can confidently refactor code, knowing that TypeScript will catch any misuses or invalid type access during compilation, before they become issues in production.
By leveraging discriminated unions, TypeScript allows you to write more expressive and maintainable code, with the added benefit of safely handling different states of your data without the need for cumbersome type assertions. This approach aligns well with the principles of ADTs, bringing their power and expressiveness into the JavaScript ecosystem.
Dart Integration
Dart has officially embraced ADTs through the introduction of sealed classes in Dart 3. This implementation is a game changer in Dart for handling complex data modeling, particularly for developers who are familiar with functional programming concepts. With sealed classes, Dart provides a robust and type-safe way to represent ADTs, making it easier to manage domain logic across various parts of an application.
In Dart, sealed classes allow you to define a set of related types where all possible subclasses are known at compile time, ensuring that your type hierarchy is both comprehensive and type-safe. Here’s how you might define the Subscription
type in Dart using sealed classes:
sealed class Subscription {}
class Trial extends Subscription {
final int remainingTrialDays;
Trial(this.remainingTrialDays);
}
class Basic extends Subscription {
final double price;
Basic(this.price);
}
class Premium extends Subscription {
final double price;
final List<String> features;
Premium(this.price, this.features);
}
This structure captures the different variants of Subscription
, similar to how you would model them in F# or TypeScript. Each variant is its own class, inheriting from the Subscription
sealed class, ensuring that all possible types are accounted for within this hierarchy.
Just like in our TypeScript scenario, we'll need to materialize the JSON into the actual ADT we've defined in Dart. Here's how we might do that:
Subscription parseSubscription(Map<String, dynamic> json) {
return switch (json) {
{'Trial': {'RemainingTrialDays': int days}} => Trial(days),
{'Basic': {'Price': double price}} => Basic(price),
{'Premium': {'Price': double price, 'Features': List features}} =>
Premium(price, List<String>.from(features)),
_ => throw Exception('Unknown Subscription type'),
};
}
As with TypeScript, we've taken the serialized ADT and converted it to one within the Dart application's boundary and thus reaping the benefits of the materialized ADT in Dart with full support.
Benefits in Dart
- Type Safety and Exhaustive Handling: Dart’s sealed classes, combined with pattern matching, ensure that your code handles all possible variants of a type safely. The compiler enforces exhaustive handling, meaning you must account for every possible subclass when working with sealed classes. This level of type safety is particularly important in large codebases, where missing a case could lead to subtle bugs. Note that this is a benefit shared between Dart and F#, but not TypeScript (to my knowledge, as of the writing of this post).
- Clarity and Expressiveness: Sealed classes in Dart allow you to define your domain model with clarity and expressiveness. Each variant of your ADT is explicitly defined, making the structure of your domain easy to understand at a glance. This clarity extends throughout your application, as the sealed class hierarchy helps maintain a consistent and logical structure.
- Integration with Flutter: The introduction of ADTs through sealed classes has significant implications for Flutter development. When building Flutter apps, managing state is often a complex task. Sealed classes simplify this by allowing you to define and manage various UI states in a structured and type-safe manner. For instance, different UI states can be modeled as subclasses of a sealed class, ensuring that your Flutter widgets handle each state appropriately and consistently.
Conclusion
I'll finish with a bit of personal philosophy. Many of us developers work across multiple codebases, and even if we aren't writing code at every boundary, we contribute to and consume the collective business knowledge of the domain. When we design types, our focus should be on modeling the domain accurately and meaningfully for the business. In contrast, designing DTOs or similar types often caters to implementation details, which can dilute the purity of the domain.
However, this doesn't have to be the case. By choosing modern languages that support algebraic data types - languages that are inherently designed around domain modeling - we can maintain the integrity of our domain, even as data crosses boundaries.
This post has demonstrated that three very different languages - F#, TypeScript, and Dart - can seamlessly send and receive data that remains true to its original structure and intent. Upon materialization, the data isn't just ready for use; it's ready in a form that reflects the business domain, enabling the compiler to enforce correctness and ensuring that the code remains aligned with the business's needs.
By embracing ADTs and the languages that support them, we keep our domain models pure and meaningful, ensuring that our software not only serves the business but does so in a way that is robust, maintainable, and true to the domain's core concepts.