layout: post
title: “Designing with types: Conclusion”
description: “A before and after comparison”
nav: thinking-functionally
seriesId: “Designing with types”
seriesOrder: 8

categories: [Types, DDD]

In this series, we’ve looked at some of the ways we can use types as part of the design process, including:

  • Breaking large structures down into small “atomic” components.
  • Using single case unions to add semantic meaning and validation to key domain types such EmailAddress and ZipCode.
  • Ensuring that the type system can only represent valid data (“making illegal states unrepresentable”).
  • Using types as an analysis tool to uncover hidden requirements
  • Replacing flags and enums with simple state machines
  • Replacing primitive strings with types that guarantee various constraints

For this final post, let’s see them all applied together.

The “before” code

Here’s the original example we started off with in the first post in the series:

  1. type Contact =
  2. {
  3. FirstName: string;
  4. MiddleInitial: string;
  5. LastName: string;
  6. EmailAddress: string;
  7. //true if ownership of email address is confirmed
  8. IsEmailVerified: bool;
  9. Address1: string;
  10. Address2: string;
  11. City: string;
  12. State: string;
  13. Zip: string;
  14. //true if validated against address service
  15. IsAddressValid: bool;
  16. }

And how does that compare to the final result after applying all the techniques above?

The “after” code

First, let’s start with the types that are not application specific. These types could probably be reused in many applications.

  1. // ========================================
  2. // WrappedString
  3. // ========================================
  4. /// Common code for wrapped strings
  5. module WrappedString =
  6. /// An interface that all wrapped strings support
  7. type IWrappedString =
  8. abstract Value : string
  9. /// Create a wrapped value option
  10. /// 1) canonicalize the input first
  11. /// 2) If the validation succeeds, return Some of the given constructor
  12. /// 3) If the validation fails, return None
  13. /// Null values are never valid.
  14. let create canonicalize isValid ctor (s:string) =
  15. if s = null
  16. then None
  17. else
  18. let s' = canonicalize s
  19. if isValid s'
  20. then Some (ctor s')
  21. else None
  22. /// Apply the given function to the wrapped value
  23. let apply f (s:IWrappedString) =
  24. s.Value |> f
  25. /// Get the wrapped value
  26. let value s = apply id s
  27. /// Equality
  28. let equals left right =
  29. (value left) = (value right)
  30. /// Comparison
  31. let compareTo left right =
  32. (value left).CompareTo (value right)
  33. /// Canonicalizes a string before construction
  34. /// * converts all whitespace to a space char
  35. /// * trims both ends
  36. let singleLineTrimmed s =
  37. System.Text.RegularExpressions.Regex.Replace(s,"\s"," ").Trim()
  38. /// A validation function based on length
  39. let lengthValidator len (s:string) =
  40. s.Length <= len
  41. /// A string of length 100
  42. type String100 = String100 of string with
  43. interface IWrappedString with
  44. member this.Value = let (String100 s) = this in s
  45. /// A constructor for strings of length 100
  46. let string100 = create singleLineTrimmed (lengthValidator 100) String100
  47. /// Converts a wrapped string to a string of length 100
  48. let convertTo100 s = apply string100 s
  49. /// A string of length 50
  50. type String50 = String50 of string with
  51. interface IWrappedString with
  52. member this.Value = let (String50 s) = this in s
  53. /// A constructor for strings of length 50
  54. let string50 = create singleLineTrimmed (lengthValidator 50) String50
  55. /// Converts a wrapped string to a string of length 50
  56. let convertTo50 s = apply string50 s
  57. /// map helpers
  58. let mapAdd k v map =
  59. Map.add (value k) v map
  60. let mapContainsKey k map =
  61. Map.containsKey (value k) map
  62. let mapTryFind k map =
  63. Map.tryFind (value k) map
  64. // ========================================
  65. // Email address (not application specific)
  66. // ========================================
  67. module EmailAddress =
  68. type T = EmailAddress of string with
  69. interface WrappedString.IWrappedString with
  70. member this.Value = let (EmailAddress s) = this in s
  71. let create =
  72. let canonicalize = WrappedString.singleLineTrimmed
  73. let isValid s =
  74. (WrappedString.lengthValidator 100 s) &&
  75. System.Text.RegularExpressions.Regex.IsMatch(s,@"^\S+@\S+\.\S+$")
  76. WrappedString.create canonicalize isValid EmailAddress
  77. /// Converts any wrapped string to an EmailAddress
  78. let convert s = WrappedString.apply create s
  79. // ========================================
  80. // ZipCode (not application specific)
  81. // ========================================
  82. module ZipCode =
  83. type T = ZipCode of string with
  84. interface WrappedString.IWrappedString with
  85. member this.Value = let (ZipCode s) = this in s
  86. let create =
  87. let canonicalize = WrappedString.singleLineTrimmed
  88. let isValid s =
  89. System.Text.RegularExpressions.Regex.IsMatch(s,@"^\d{5}$")
  90. WrappedString.create canonicalize isValid ZipCode
  91. /// Converts any wrapped string to a ZipCode
  92. let convert s = WrappedString.apply create s
  93. // ========================================
  94. // StateCode (not application specific)
  95. // ========================================
  96. module StateCode =
  97. type T = StateCode of string with
  98. interface WrappedString.IWrappedString with
  99. member this.Value = let (StateCode s) = this in s
  100. let create =
  101. let canonicalize = WrappedString.singleLineTrimmed
  102. let stateCodes = ["AZ";"CA";"NY"] //etc
  103. let isValid s =
  104. stateCodes |> List.exists ((=) s)
  105. WrappedString.create canonicalize isValid StateCode
  106. /// Converts any wrapped string to a StateCode
  107. let convert s = WrappedString.apply create s
  108. // ========================================
  109. // PostalAddress (not application specific)
  110. // ========================================
  111. module PostalAddress =
  112. type USPostalAddress =
  113. {
  114. Address1: WrappedString.String50;
  115. Address2: WrappedString.String50;
  116. City: WrappedString.String50;
  117. State: StateCode.T;
  118. Zip: ZipCode.T;
  119. }
  120. type UKPostalAddress =
  121. {
  122. Address1: WrappedString.String50;
  123. Address2: WrappedString.String50;
  124. Town: WrappedString.String50;
  125. PostCode: WrappedString.String50; // todo
  126. }
  127. type GenericPostalAddress =
  128. {
  129. Address1: WrappedString.String50;
  130. Address2: WrappedString.String50;
  131. Address3: WrappedString.String50;
  132. Address4: WrappedString.String50;
  133. Address5: WrappedString.String50;
  134. }
  135. type T =
  136. | USPostalAddress of USPostalAddress
  137. | UKPostalAddress of UKPostalAddress
  138. | GenericPostalAddress of GenericPostalAddress
  139. // ========================================
  140. // PersonalName (not application specific)
  141. // ========================================
  142. module PersonalName =
  143. open WrappedString
  144. type T =
  145. {
  146. FirstName: String50;
  147. MiddleName: String50 option;
  148. LastName: String100;
  149. }
  150. /// create a new value
  151. let create first middle last =
  152. match (string50 first),(string100 last) with
  153. | Some f, Some l ->
  154. Some {
  155. FirstName = f;
  156. MiddleName = (string50 middle)
  157. LastName = l;
  158. }
  159. | _ ->
  160. None
  161. /// concat the names together
  162. /// and return a raw string
  163. let fullNameRaw personalName =
  164. let f = personalName.FirstName |> value
  165. let l = personalName.LastName |> value
  166. let names =
  167. match personalName.MiddleName with
  168. | None -> [| f; l |]
  169. | Some middle -> [| f; (value middle); l |]
  170. System.String.Join(" ", names)
  171. /// concat the names together
  172. /// and return None if too long
  173. let fullNameOption personalName =
  174. personalName |> fullNameRaw |> string100
  175. /// concat the names together
  176. /// and truncate if too long
  177. let fullNameTruncated personalName =
  178. // helper function
  179. let left n (s:string) =
  180. if (s.Length > n)
  181. then s.Substring(0,n)
  182. else s
  183. personalName
  184. |> fullNameRaw // concat
  185. |> left 100 // truncate
  186. |> string100 // wrap
  187. |> Option.get // this will always be ok

And now the application specific types.

  1. // ========================================
  2. // EmailContactInfo -- state machine
  3. // ========================================
  4. module EmailContactInfo =
  5. open System
  6. // UnverifiedData = just the EmailAddress
  7. type UnverifiedData = EmailAddress.T
  8. // VerifiedData = EmailAddress plus the time it was verified
  9. type VerifiedData = EmailAddress.T * DateTime
  10. // set of states
  11. type T =
  12. | UnverifiedState of UnverifiedData
  13. | VerifiedState of VerifiedData
  14. let create email =
  15. // unverified on creation
  16. UnverifiedState email
  17. // handle the "verified" event
  18. let verified emailContactInfo dateVerified =
  19. match emailContactInfo with
  20. | UnverifiedState email ->
  21. // construct a new info in the verified state
  22. VerifiedState (email, dateVerified)
  23. | VerifiedState _ ->
  24. // ignore
  25. emailContactInfo
  26. let sendVerificationEmail emailContactInfo =
  27. match emailContactInfo with
  28. | UnverifiedState email ->
  29. // send email
  30. printfn "sending email"
  31. | VerifiedState _ ->
  32. // do nothing
  33. ()
  34. let sendPasswordReset emailContactInfo =
  35. match emailContactInfo with
  36. | UnverifiedState email ->
  37. // ignore
  38. ()
  39. | VerifiedState _ ->
  40. // ignore
  41. printfn "sending password reset"
  42. // ========================================
  43. // PostalContactInfo -- state machine
  44. // ========================================
  45. module PostalContactInfo =
  46. open System
  47. // InvalidData = just the PostalAddress
  48. type InvalidData = PostalAddress.T
  49. // ValidData = PostalAddress plus the time it was verified
  50. type ValidData = PostalAddress.T * DateTime
  51. // set of states
  52. type T =
  53. | InvalidState of InvalidData
  54. | ValidState of ValidData
  55. let create address =
  56. // invalid on creation
  57. InvalidState address
  58. // handle the "validated" event
  59. let validated postalContactInfo dateValidated =
  60. match postalContactInfo with
  61. | InvalidState address ->
  62. // construct a new info in the valid state
  63. ValidState (address, dateValidated)
  64. | ValidState _ ->
  65. // ignore
  66. postalContactInfo
  67. let contactValidationService postalContactInfo =
  68. let dateIsTooLongAgo (d:DateTime) =
  69. d < DateTime.Today.AddYears(-1)
  70. match postalContactInfo with
  71. | InvalidState address ->
  72. printfn "contacting the address validation service"
  73. | ValidState (address,date) when date |> dateIsTooLongAgo ->
  74. printfn "last checked a long time ago."
  75. printfn "contacting the address validation service again"
  76. | ValidState _ ->
  77. printfn "recently checked. Doing nothing."
  78. // ========================================
  79. // ContactMethod and Contact
  80. // ========================================
  81. type ContactMethod =
  82. | Email of EmailContactInfo.T
  83. | PostalAddress of PostalContactInfo.T
  84. type Contact =
  85. {
  86. Name: PersonalName.T;
  87. PrimaryContactMethod: ContactMethod;
  88. SecondaryContactMethods: ContactMethod list;
  89. }

Conclusion

Phew! The new code is much, much longer than the original code. Granted, it has a lot of supporting functions that were not needed in the original version, but even so it seems like a lot of extra work. So was it worth it?

I think the answer is yes. Here are some of the reasons why:

The new code is more explicit

If we look at the original example, there was no atomicity between fields, no validation rules, no length constraints, nothing to stop you updating flags in the wrong order, and so on.

The data structure was “dumb” and all the business rules were implicit in the application code.
Chances are that the application would have lots of subtle bugs that might not even show up in unit tests. (Are you sure the application reset the IsEmailVerified flag to false in every place the email address was updated?)

On the other hand, the new code is extremely explicit about every little detail. If I stripped away everything but the types themselves, you would have a very good idea of what the business rules and domain constraints were.

The new code won’t let you postpone error handling

Writing code that works with the new types means that you are forced to handle every possible thing that could go wrong, from dealing with a name that is too long, to failing to supply a contact method.
And you have to do this up front at construction time. You can’t postpone it till later.

Writing such error handling code can be annoying and tedious, but on the other hand, it pretty much writes itself. There is really only one way to write code that actually compiles with these types.

The new code is more likely to be correct

The huge benefit of the new code is that it is probably bug free. Without even writing any unit tests, I can be quite confident that a first name will never be truncated when written to a varchar(50) in a database, and that I can never accidentally send out a verification email twice.

And in terms of the code itself, many of the things that you as a developer have to remember to deal with (or forget to deal with) are completely absent. No null checks, no casting, no worrying about what the default should be in a switch statement. And if you like to use cyclomatic complexity as a code quality metric, you might note that there are only three if statements in the entire 350 odd lines.

A word of warning…

Finally, beware! Getting comfortable with this style of type-based design will have an insidious effect on you. You will start to develop paranoia whenever you see code that isn’t typed strictly enough. (How long should an email address be, exactly?) and you will be unable to write the simplest python script without getting anxious. When this happens, you will have been fully inducted into the cult. Welcome!

If you liked this series, here is a slide deck that covers many of the same topics. There is a video as well (here)