Quatro / Cinco - Step 2
As we converted our model to F#, we brought in some immutability. What we created can form the basis of a
fully operational F# application; but we can do better. For example, given "faad05f6-c539-44b7-9e94-1b68da4bba57" -
quick! Is it a post Id? A page Id? The text of a really lame blog post? Also, what is to prevent us from using a
CommentStatus
value in a spot where a PostStatus
should go? (Do you really want your own post to be able to be
flagged as spam?)
To be sure, these same problems exist in most OO realms, and developers manage to keep all the strings separate. What
the good ones do is write unit tests that construct these invalid states, and ensure that their application handles them
gracefully. However, just as immutability gets rid of null
checks, F# has features that go even further, and can help
us create a model where invalid states cannot be represented. F# for Fun and Profit has a great series on
Designing with Types, and I highly recommend
reading it; it goes into way more depth that we're going to at this point.
The language feature we're going to use is called discriminated unions (or "DUs" for short). You've probably dealt
with enum
s in C#; that is the closest parallel to DUs, but there are significant differences. Like enum
s, DUs are
an exhaustive list of all expected/valid values. Unlike enum
s, though, they are not wrappers over another type; they
are their own type. Also, each condition does not have to have the same type; it's perfectly valid to have a DU with
one condition that has one type (or no type at all), and other condition with a completely different type. (We don't
use that with these types.)
To start, bring the Domain.fs
file over from Tres. The first type of DU we'll use is called a single-case
discriminated union; it can be used to wrap primitives to make them more meaningful. We'll create the following
single-case DUs at the top of the file, before our other types:
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: |
|
It may be confusing that we're using the same name twice; the name after the type
keyword defines the type, while the
one after the equals sign defines the constructor for this type (CategoryId "abc"
defines a category Id whose value
is the string "abc"). We'll look at these implemented in a bit; next, though, we'll convert our
static-classes-turned-modules into multi-case DUs.
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: |
|
This is similar in concept to the single-case DUs, but there are no parameters required for the constructor.
If you are following along in the finished code, at this point, you're thinking "Why did he skip ArticleContent
?" If
you take a look at
the definitions of IArticleContent
and its implementations,
it's not too bad; the interface and both implementations fit well within a viewing window. But, seeing all three there
together, we can spot a lot of repeated code and ceremony. When it comes down to it, we really just need to know if a
string has HTML or Markdown, so we can a) either display it as-is or process it before displaying it; and b) populate
the right editor when we're editing posts and pages.
We haven't encountered it yet (though we will when start implementing Tres), but working with interfaces in F# can be a bit of a pain. If you mean to call code that is specified by the interface, you have to either cast the implementation to the interface type, or code these casts into the type's properties (which means you end up writing two implementations). However, this is a great case for a multi-case DU with types.
1: 2: 3: 4: 5: 6: |
|
This code does what the other did, but even more succinctly, and will require no special casts. This also shows how we
can add instance methods to DUs. We may end up removing this from here, and making a separate render
function that
takes an ArticleContent
and produces HTML; but, for now, these 6 lines replace 4 files in C# and 4 types from
Tres.
Now that we have defined our specific types, we can apply them to our record types to more precisely specify the shape
of the information. Let's revisit the Page
type we dissected for Tres.
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: |
|
The only primitives* we now have are the Title
field (which is free-form text) and the ShowInPageList
field (for
which yes/no is sufficient, although we could create a PageListVisibility
DU to further constrain the yes/no values
and distinguish them from others). The compiler will prevent us from crossing boundaries on every other field in this
type!
Let's take a look at the Empty
property on the Post
type to see a multi-case DU in use.
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: |
|
Status
is defined as type PostStatus
; to set its value, we simply have to write Draft
. No quotes, no dotted
access**, just Status = Draft
. (Status = Spam
does not compile.)
One final thing; notice the top of the file...
1:
|
|
While this file would have no functional difference whether the top-level definition were namespace
or module
,
defining our files as modules can have some advantages. Back in step 1, we had to create modules where we wouldn't
necessarily have needed them, simply because you can have let
statements directly in a namespace. Were we to change
the first line in App.fs
to module Quatro.App
instead of namespace Quatro
(or Cinco), we could end the file with
the main
function. We'll make this change to those files as part of the next step.
You can review the entire set of types to see where these various DUs were used. While we could certainly take this much further, these simple changes have made our types more meaningful, while eliminating a lot of the invalid states we could have assigned in our code.
* - string
is a primitive for our purposes here.
** - If our DU condition is not unique, it may need to be qualified. For example, if we were to add a "Draft"
CommentStatus
so we could auto-save comment text while the visitor was typing***, we would need to change the
Empty
property to assign PostStatus.Draft
instead. Again, though, the compiler would help us spot that right away.
*** - This is a really bad idea; don't do this.
val string : value:'T -> string
--------------------
type string = System.String
type Markdown =
new : unit -> Markdown + 2 overloads
member AsteriskIntraWordEmphasis : bool with get, set
member AutoHyperlink : bool with get, set
member AutoNewLines : bool with get, set
member EmptyElementSuffix : string with get, set
member LinkEmails : bool with get, set
member StrictBoldItalic : bool with get, set
member Transform : text:string -> string
member Version : string
--------------------
MarkdownSharp.Markdown() : MarkdownSharp.Markdown
MarkdownSharp.Markdown(loadOptionsFromConfigFile: bool) : MarkdownSharp.Markdown
MarkdownSharp.Markdown(options: MarkdownSharp.MarkdownOptions) : MarkdownSharp.Markdown
val int64 : value:'T -> int64 (requires member op_Explicit)
--------------------
type int64 = System.Int64
--------------------
type int64<'Measure> = int64
| Html of string
| Markdown of string
member Generate : unit -> string
union case CategoryId.CategoryId: string -> CategoryId
--------------------
type CategoryId = | CategoryId of string
union case CommentId.CommentId: string -> CommentId
--------------------
type CommentId = | CommentId of string
union case PageId.PageId: string -> PageId
--------------------
type PageId = | PageId of string
union case PostId.PostId: string -> PostId
--------------------
type PostId = | PostId of string
union case UserId.UserId: string -> UserId
--------------------
type UserId = | UserId of string
union case WebLogId.WebLogId: string -> WebLogId
--------------------
type WebLogId = | WebLogId of string
union case Permalink.Permalink: string -> Permalink
--------------------
type Permalink = | Permalink of string
union case Tag.Tag: string -> Tag
--------------------
type Tag = | Tag of string
union case Ticks.Ticks: int64 -> Ticks
--------------------
type Ticks = | Ticks of int64
union case TimeZone.TimeZone: string -> TimeZone
--------------------
type TimeZone = | TimeZone of string
union case Url.Url: string -> Url
--------------------
type Url = | Url of string
| Administrator
| User
| Draft
| Published
| Approved
| Pending
| Spam
Page.WebLogId: WebLogId
--------------------
type WebLogId = | WebLogId of string
Page.Permalink: Permalink
--------------------
type Permalink = | Permalink of string
{AsOf: int64;
Text: ArticleContent;}
static member Empty : Revision
Post.WebLogId: WebLogId
--------------------
type WebLogId = | WebLogId of string
Post.Permalink: string
--------------------
type Permalink = | Permalink of string