HERE are the basic points of idiomatic (according to me) F# design.
The idioms I present below are not the ones generally used in the F#
community; they are fairly controversial. But they are similar
to idioms used in the wider ML languages, and I genuinely believe they
offer value in terms of easy-to-read code.
Prefer modules, records, and functions
Try to avoid classes, because they are somewhat higher-ceremony than
plain F# records. For example: records can be destructured effortlessly
and updated immutably by replacing the values of named fields with new
values.
Avoid member methods and properties
They don't mesh well with the functional style because they can't be
passed around directly. Module member values and functions can.
Put everything inside modules
Ideally, dedicate a module to a (primary) type and name the type just
t
to avoid repetition; refer to the type (and other module
members) prefixed by the module name for maximum clarity.
In fact, put everything (types, exceptions, and values) inside
modules; they are an excellent organisational tool and clarify your
thinking about what things go together.
Design top-down using interface (.fsi) files
F# is fantastic for top-down design because you can write the
signatures of your modules, data, and operations separately as
syntactically valid code in .fsi (F# interface) files and then fill in
the blanks later with the actual implementation (.fs) files. Even if you
don't consider yourself a top-down thinker, give it a try and you may be
surprised by F#'s expressive power here.
Interface files are like C/C++ header files, and let you hide any
module members you don't want to expose. Crucially, they also let you
hide data definitions and give your caller abstract types to work with,
enabling information hiding and decoupling. Quick example:
(* person.fsi *)
namespace MyProject
module Person =
type t
val make : string -> t
val id : t -> int64
val name : t -> string
val with_name : string -> t -> t
(* person.fs *)
namespace MyProject
module Person =
type t = { id : int64; name : string }
let make name = { id = Id_service.get (); name = name }
let id { id = i } = i
let name { name = n } = n
let with_name new_name t = { t with name = new_name }
In the above example, we hide the Person.t
type's
implementation details so that callers can't create values of the type
themselves bypassing our API. Nor can they operate on values of the type
in any way except the ones we provide as part of the Person
module.
Also, we take advantage of the above-mentioned F# destructuring
pattern matching and immutable record update--benefits we don't get from
OOP style--to quickly access and change exactly the parts of the data
we're interested in.
Use classic ML-style type parameter application
The recommended style of type parameter application in .NET is with
angle brackets after the type constructor. But this is noisy, and just
gets noisier as your types get more complex. Use ML-style, which
reverses the order of application to postfix, but gets rid of (most of)
the noise. Let's compare:
type a1 = Async<option<Person.t>>
type a2 = Person.t option Async
type b1 = Choice<string, Person.t>
type b2 = (string, Person.t) Choice
type c1<'a> = Map<string, 'a>
type 'a c2 = (string, 'a) Map
ML-style type application gives you the biggest wins for sequences of
single-parameter applications, but it still spaces out the code in
general, which helps with reading.
Use type parameters to control allowed operations
When your type has operations that are only valid when the type is in
a certain state, then you can express it as a discriminated
union (a sum type) so that operations work in different ways for
different cases of the DU. But the problem with exposing the cases of a
DU is that you lose decoupling--you can't easily control future
refactoring of the type because user code will depend on existing DU
cases.
Another problem is, maybe certain operations don't work for
all states of a type. Let's try an example:
namespace MyProject
module Bulb =
type t = On | Off
exception Already_on
exception Already_off
let turn_on = function
| Off -> On
| On -> raise Already_on
let turn_off = function
| On -> Off
| Off -> raise Already_off
Both of our critical operations on the Bulb.t
type are
raising exceptions if called wrongly. Sure, we could have returned a
None : Bulb.t option
instead if something went wrong; but
that just passes the checking to the caller somewhere else. There is a
better way: phantom types. Here's the above example converted:
(* bulb.fsi *)
namespace MyProject
module Bulb =
type on
type off
type 'a t
val on : on t
val off : off t
val turn_on : off t -> on t
val turn_off : on t -> off t
(* bulb.fs *)
namespace MyProject
module Bulb =
type on = interface end
type off = interface end
(* Phantom type--left-hand type parameter isn't used on right hand. *)
type 'a t = On | Off
let on = On
let off = Off
(*
Compile warnings are OK here because we're controlling allowed inputs.
*)
let turn_on Off = On
let turn_off On = Off
Note that the compiler warns us here about incomplete pattern matches
on the Bulb.t
type, but we ignore them in this case because
we're explicitly controlling allowable function inputs at the type
level. We can turn off this warning with a #nowarn "25"
compiler directive in the bulb.fs
file, but in general
turning off warnings is not a good idea.
Prefer records of functions to interfaces
Here
is a good write-up about this, but let me reiterate: interface methods
can't be easily passed around, unlike record member functions (which can
be closures, remember). An example:
(* api.fs *)
namespace MyProject
module Api =
exception Invalid_id
type 'a t =
{ get_exn : int64 -> 'a Async
add : 'a -> unit Async
remove_exn : int64 -> unit Async
list : unit -> 'a seq Async }
(* person.fs *)
namespace MyProject
module Person =
type t = { id : int64; name : string }
let make id name = { id = id; name = name }
let id { id = i } = i
let name { name = n } = n
(* val api : t Api.t *)
let api =
{ get_exn = fun id -> ...
add = fun person -> ...
remove_exn = fun id -> ...
list = fun () -> ... }
Above we implement a generic data store API and a domain type that
implements the API. Note that as a convention we add an
_exn
suffix to code which might raise an exception. This
helps the reader prepare to reason about exception handling.
Wrap OOP-style APIs in modular F#
When dealing with traditional OOP-style APIs, e.g. Windows Forms, try
to wrap them up in modular F# and expose only the F# abstraction. E.g.,
suppose you're writing a simple GUI calculator app in WinForms. You need
to (1) write out the logic and make it testable; and (2) render the GUI
in a form. Solution--put these things in a module:
(* calculator.fsi *)
namespace CalculatorApp
module Calculator =
type number = Zero | One | Two | ... | Nine
type op = Plus | Minus | ... | Sqrt
type t
val init : t
(*
We will implement these operations as mutating to allow a more fluent,
pipeline-based API like:
let result =
calc
|> clear
|> press_number Five
|> press_op Plus
|> press_number Six
|> calculate
At this point, `calc` is in the state that it holds a calculation
result. We can `clear` it to do more calculations.
*)
val clear : t -> t
val press_number : number -> t -> t
val press_op : op -> t -> t
val press_decimal : t -> t
val calculate : t -> double
(*
Draws the app and hooks up event handlers to the above operations.
*)
val render : t -> System.Windows.Forms.Control
Now in the implementation, follow the types to implement the business
logic and the GUI. Note how the logic is testable because the main
operations are exposed, but the messy OOP GUI rendering is not--the
caller just gets back a Control
to embed in their main
window. This is composable and readable.
General philosophy
To conclude, we aim for these design points:
- Information hiding with interface files
- Simpler, less noisy syntax is better
- Modular is better--ideally everything is in a module
- Logic encoded in the types is better--you get compile-time
guarantees and have to do less runtime error handling.