Reputation: 8997
I am creating a sort of "envelope" to keep track of what (Commands and Events) I am sending over RabbitMQ in an application as follows:
type Envelope<'T> = {
Id: Guid
CausationId: Guid
CorrelationId: Guid
Payload: 'T
Timestamp: DateTimeOffset
}
'T
being usually a command or event of the form:
type Commands =
| DoA of Stuff
| DoB of Stuff
| DoC of Stuff
type Events =
| AHappened of Stuff
| BHappened of Stuff
| CHappened of Stuff
Note: I purposefully use relatively generic names
However, I could also have used specific types:
type CausationId = CausationId of Guid
type CorrelationId = CorrelationId of Guid
type CommandId = CommandId of Guid
type EventId = EventId of Guid
type Timestamp = Timestamp of DateTimeOffset
type DataToDoA = DataToDoA of Stuff
type DataToDoB = DataToDoB of Stuff
type DataToDoC = DataToDoC of Stuff
type Commands =
| DoA of DataToDoA
| DoB of DataToDoB
| DoC of DataToDoC
type DataLeftByA = DataLeftByA of Stuff
type DataLeftByB = DataLeftByB of Stuff
type DataLeftByC = DataLeftByC of Stuff
type Events =
| AHappened of DataLeftByA
| BHappened of DataLeftByB
| CHappened of DataLeftByC
Which would have led to:
type CommandEnvelope<`T> = {
Id: CommandId
CausationId: CausationId
CorrelationId: CorrelationId
Payload: `T
Timestamp: Timestamp
}
type EventEnvelope<`T> = {
Id: EventId
CausationId: CausationId
CorrelationId: CorrelationId
Payload: `T
Timestamp: Timestamp
}
Is there a rule of thumb to decide when to use those specific types / aliases-ish?
Upvotes: 1
Views: 77
Reputation: 2937
Scott Wlaschin of F# for Fun and Profit talks a bit about this in his "Designing with Types" series. In the post on Single Case Union Types, he explains that one of the main reasons for using single-case union-types is when you want to add some validation logic. This allows you to refine your type a bit more.
The example he gives is email. You could have type Person = { ... Email: string ... }
but this does not provide any guarantee that the string is an email address. Making a newtype for it type EmailAddress = Email of string
you can then you can only create email addresses from functions which validate it first. This can be a good sanity check and helps make sure you don't assign email addresses to things which aren't addresses/links.
TL;DR Single-case union types are useful for whenever you want to add further semantics/validation to your code.
Upvotes: 2
Reputation: 243041
I don't think there is a rule of thumb for this, but one systematic way of thinking about this is to consider whether values represented by those extra types will ever need to be passed around in your code on their own. If no, then you do not need those extra types. If yes, then they might be useful.
Let's say you have just Stuff
and:
type DataLeftByA = DataLeftByA of Stuff
type DataLeftByB = DataLeftByB of Stuff
type Events =
| AHappened of DataLeftByA
| BHappened of DataLeftByB
One thing that this lets you do is to write a function:
let processDataA (DataLeftByA stuff) = (...)
This function takes DataLeftByA
containing some Stuff
. However, the type makes it clear that the function should only be used for events caused by A
. The following will be a type error:
let handleEvent = function
| AHappened adata -> processDataA adata
| BHappened bdata -> processDataA bdata // Type error here!
If you defined your Events
as just events contianing the same Stuff
:
type Events =
| AHappened of Stuff
| BHappened of Stuff
Then the data that your events carry is the same, but you are losing the ability to define a function such as processDataA
, because there is no separate type for data carried by event A
. You can just define processStuff
, but that can then be invoked in both the A
and B
case.
I think this is the only thing that makes a real practical difference between the two versions. So, the rule of thumb would be - do you ever need to define a function like processDataA
or not?
Upvotes: 3