Natalie Perret
Natalie Perret

Reputation: 8997

F#: Rule of thumb to use specific types / aliased types?

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

Answers (2)

Edward Minnix
Edward Minnix

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

Tomas Petricek
Tomas Petricek

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

Related Questions