Exploring 4 Languages: Starting to Model the Domain
How we use types to capture business concepts in Rust, Elm, Fâ¯, and ReasonML.
In the first three chapters of Domain Modeling Made Functional, Wlaschin walks through the creation of a âdomain modelâ for an order-taking system. (Itâs well worth reading the book just for a bunch of the lessons in that sectionâI found them quite helpful!) Then, after spending a chapter introducing Fâ¯âs type system, he introduces the ways you can use those type mechanics to express the domain. In todayâs post, Iâll show the idiomatic implementations of these types in each of Rust, Elm, Fâ¯, and ReasonML.
Simple values
Simple wrapper types let you take simple types like strings, numbers, etc. and use types to represent part of the business domain youâre dealing withâthe basic idea being that a Customer ID may be a number, but itâs not interchangeable with other numbers such as Order IDs.
Hereâs the most ergonomic and effective (and automatically-formatted in line with the language standards, where applicable!) way to do that in each of the languages:
Rust:
struct CustomerId(i32);
Elm:
type CustomerId
= CustomerId Int
Fâ¯:
type CustomerId = CustomerId of int
ReasonML:
type customerId =
| CustomerId(int);
Note how similar these all are! The Rust implementation is the most distinctive, though you can do it with the same kind of union type as the others. Hereâs how that would look:
enum CustomerId {
CustomerId(i32),
}
For performance reasons, you might also choose to implement the F⯠type as a struct:
<Struct>
type CustomerId = CustomerId of int
Complex data
Wlaschin then moves on to showing how to model more complex data structures: types that âandâ or âorâ together other data. We âandâ data together using record or struct types, and âorâ data together using âunionâ or âenumâ types. (Assume weâve defined CustomerInfo
, ShippingAddress
, etc. types for all of these.)
Rust:
// "and"
struct Order {
customer_info: CustomerInfo,
shipping_address: ShippingAddress,
billing_address: BillingAddress,
order_lines: Vec<OrderLine>,
billing_amount: BillingAmount,
}
// "or"
enum ProductCode {
Widget(WidgetCode),
Gizmo(GizmoCode),
}
Elm:
-- "and"
type alias Order =
{ customerInfo : CustomerInfo
, shippingAddress : ShippingAddress
, billingAddress : BillingAddress
, orderLines : List OrderLine
, billingAmount : BillingAmount
}
-- "or"
type ProductCode
= Widget WidgetCode
| Gizmo GizmoCode
Fâ¯:
// "and"
type Order = {
CustomerInfo : CustomerInfo
ShippingAddress : ShippingAddress
BillingAddress : BillingAddress
OrderLines : OrderLine list
AmountToBill: BillingAmount
}
// "or"
type ProductCode =
| Widget of WidgetCode
| Gizmo of GizmoCode
ReasonMLânote that since weâre assuming weâve already defined the other types here, you can write this without duplicating the name and type declaration, just like you can with JavaScript object properties.
/* "and" */
type order = {
customerInfo,
shippingAddress,
billingAddress,
orderLine,
billingAmount
};
/* "or" */
type productCode =
| Widget(widgetCode)
| Gizmo(gizmoCode);
An interesting aside: unless you planned to reuse these types, you wouldnât usually write these as standalone types with this many wrapper types in it in Rust in particular (even if the compiler would often recognize that it could squash them down for you).1 Instead, youâd normally write only the base enum type to start, and refactor out the struct
wrapper later only if you found you needed it elsewhere:
enum ProductCode {
- Widget(WidgetCode),
+ Widget(String),
- Gizmo(GizmoCode),
+ Gizmo(String),
}
That said: given how the book is tackling things, and the fact that you might want to validate these types⦠having them as these low-cost wrappers is probably worth it. (In fact, having read a bit further than Iâve managed to write out yet, I can guarantee it.)
We work through the rest of the basic types this way. But what about the types where we donât yet have a good idea how we want to handle them?
Each of these languages gives us an out (or more than one) for how to say âI donât know what to put here yet.â
Rust (which does not have a built-in Never
type⦠yet; see below):
// Make an empty enum (which you by definition cannot construct)
enum Never {}
// Use it throughout where we don't know the type yet. It will fail to compile
// anywhere we try to *use* this, because you can't construct it.
type OrderId = Never;
Elm (which has a built-in Never
type):
-- It will fail to compile anywhere we try to *use* this, because you cannot
-- construct `Never`.
type alias OrderId =
Never
F⯠(which sort of does):
// Make a convenience type for the `exn`/`System.Exception` type
type Undefined = exn
type OrderId = Undefined
Reason (which also sort of doesâidentically with Fâ¯):
/* Make a convenience type for the `exn`/`System.Exception` type */
type undefined = exn;
/*
Use it throughout where we don't know the type yet. It will compile, but fail
to run anywhere we try to *use* this.
*/
type orderId = undefined;
For both F⯠and Reason, thatâs following Wlaschinâs example. The main reason to do that is to make explicit that weâre not actually wanting an exception type in our domain model, but just something we havenât yet defined. Anywhere we attempted to use it, weâd have to handle it like, well⦠an exception, instead of an actual type.
type OrderId = !;
Workflows and functions
Once we have the basic types themselves in place, we need to write down the ways we transform between them. In a functional style, weâre not going to implement instance methodsâthough as weâll see in the next post, what we do in Rust will have some similarities to class methodsâweâre going to implement standalone functions which take types and return other types.
Again, youâll note that despite the common lineage, there is a fair amount of variation here. (Note that weâd also have defined the UnvalidatedOrder
, ValidationError
, and ValidatedOrder
types for all of this; Iâm mostly interested in showing new differences here.)
Rust (using the Futures library to represent eventual computation):
type ValidationResponse<T> = Future<Item = T, Error = ValidationError>;
fn validate_order(unvalidated: UnvalidatedOrder) -> Box<ValidationResponse<ValidatedOrder>> {
unimplemented!()
}
Elm (using the built-in Task
type for eventual computation; Task
s encapsulate both eventuality and the possibility of failure):
type ValidationResponse a
= Task (List ValidationError) a
type alias ValidateOrder =
UnvalidatedOrder -> ValidationResponse ValidatedOrder
F⯠(using the built-in Async
type for eventual computation):
type ValidationResponse<'a> = Async<Result<'a,ValidationError list>>
type ValidateOrder =
UnvalidatedOrder -> ValidationResponse<ValidatedOrder>
Reason (using the built-in JavaScript-specific Js.Promise
typeâwhich is exactly what it sounds likeâfor eventual computation):
type validationResponse('a) = Js.Promise.t(Js.Result.t('a, list(validationError)));
type validateOrder = unvalidatedOrder => validationResponse(validatedOrder);
Once again Rust is much more different here from the others than they are from each other. The biggest difference between Elm, Fâ¯, and Reason is how they handle generics and type parameters.
Youâll note that in Elm, they just follow the name of the wrapping type. This is a kind of syntactic symmetry: the way you name a generic type like this is the same basic way you construct it. Itâs quite elegant. And as it turns out, the same is true of Reason; itâs just that its authors have chosen to follow OCaml and use parentheses for them instead of following Haskell with spacesâa reasonable choice, given Reason is surface syntax for OCaml and not Haskell.
F⯠uses angle brackets, I strongly suspect, because thatâs what C# uses for generics, and keeping them syntactically aligned in things like this is very helpful. Rust similarly uses angle brackets for similarity with other languages which have similar surface syntaxâespecially C++ (with its templates).
The way you name generic parameters differs between the languages as well. Elm, following Haskell, uses lowercase letters to name its generics (usually called type parameters in Elm). F# and Reason both (unsurprisingly) follow OCaml in using lowercase letters preceded by an apostrophe to name genericsâin F#, TypeGenericOver<'a>
; in Reason, typeGenericOver('a)
. Rust follows the convention from languages like C++, Java, and C# and uses capital letters, TypeGenericOver<T>
. The use of specific letters is conventional, not mandated by the language (unlike the casing). The ML family usually starts with a
and moves through the alphabet; Rust and the languages it follows usually start with T
(for type) and moves forward through the alphabet. (Sometimes youâll also see different letters where itâs obviously a better fit for whatâs contained.)
These languages also vary in the syntax for constructing a list of things. In F# has convenience syntax for a few built-ins (the most common being the List
and Option
types), allowing you to write them either as e.g. List<ConcreteType>
or ConcreteType list
(as here in the example). Elm, Reason, and Rust all just use the standard syntax for generic typesâList a
, list('a)
, and Vec<T>
respectively.
Finally, youâll also note that we havenât written out a type declaration here for Rust; weâve actually written out a stub of a function, with the unimplemented!()
macro. If you invoke this function, youâll get a clear crash with an explanation of which function isnât implemented.
Now, Rust also does let us write out the type of these functions as type aliases if we want:
type ValidateOrder =
Fn(UnvalidatedOrder) -> Box<ValidationResponse<ValidatedOrder>>;
You just donât use these very often in idiomatic Rust; itâs much more conventional to simply write out what I did above. However, the one time you might use a type alias like this is when youâre defining the type of a closure and you donât want to write it inline. This is a pretty sharp difference between Rust and the other languages on display here, and it goes to the difference in their approaches.
Rust is not a functional-first language in the way that each of the others are, though it certainly draws heavily on ideas from functional programming throughout and makes quite a few affordances for a functional style. Instead, itâs a programming language first and foremost interested in combining the most screaming performance possible with true safety, and leaning on ideas from the ML family (among others!) as part of achieving that.
Among other things, this is why you donât have currying or partial application in Rust: those essentially require you to have invisible heap-allocation to be ergonomic. We donât have that in Rust, as we do in Elm, Reason, and Fâ¯. If we want to pass around a function, we have to explicitly wrap it in a pointer to hand it around if we construct it in another function. (I wonât go into more of the details of this here; Iâve covered it some on New Rustacean and some in my Rust and Swift comparison a couple years ago.)
That same underlying focus on performance and explicitness is the reason we have Box<ValidationResponse<ValidatedOrder>>
in the Rust case: weâre explicitly returning a pointer to the type here. In Elm, Fâ¯, and Reason, thatâs always the case. But in Rust, you can and often do return stack-allocated data and rely on âmoveâ semantics to copy or alias it properly under the hood.
Summary
So: lots of similarities here at first blush. The biggest differences that show up at this point are purely syntactical, other than some mildly sharper differences with Rust because of its focus on performance. The fact that these languages share a common lineage means itâs not hard to read any of them if youâre familiar with the others, and itâs actually quite easy to switch between them at the levels of both syntax and semantics.
As usual, when dealing with languages in a relatively similar family, itâs most difficult to learn the library differences. The most obvious example of that here is Reasonâs Js.Promise
, Elmâs Task
, Fâ¯âs Async
, and Rustâs Future
types: each of those has their own quirks, their own associated helper functions or methods, and their own ways of handling the same basic patterns.
Still, if you have played with any one of these, you could pretty easily pick up one of the others. Itâs sort of like switching between Python and Ruby: there are some real differences there, but the similarities are greater than the differences. Indeed, if anything, these languages are more similar than those.
Next time Iâll dig into Wlaschinâs chapter on validating the domain model, and here some of the not-just-syntax-level differences in the languages will start to become more apparent.
I canât speak to whatâs idiomatic this way in any of the non-Rust languages, because I just havenât used them enough yet.â©