Require at least one of two parameters

I’m wondering if anyone has any ideas for how to make this interface nicer.

I have a function which creates an email which is either plain text, html or both. Emails with no content are not allowed. Currently, the function looks like this:

type t =
  { plain_content : string option
  ; html_content : string option
  (* in real life more fields here *) }

let make ?plain_content ?html_content () =
  match plain_content, html_content with
  | None, None -> failwith "Need to set either ~plain_content or ~html_content (or both)"
  | _ -> ();
  { plain_content ; html_content }

I’m thinking of replacing this with a variant like:

let make ~content () =
  let plain_content, text_content =
    match content with
    | `Plain plain -> Some plain, None
    | `Html html -> None, Some html
    | `Plain_and_html (plain, html) -> Some plain, Some html
  in
  { plain_content ; html_content }

This is nice because the exception case becomes impossible, but since it’s just two string parameters, it’s relatively easy to get the order wrong, like make ~content:Plain_and_html (html, plain).

I could go farther and do this:

type plain_and_html = { plain : string ; html : string }

let make ~content () =
  let plain_content, text_content =
    match content with
    | `Plain plain -> Some plain, None
    | `Html html -> None, Some html
    | `Plain_and_html { plain ; html } -> Some plain, Some html
  in
  { plain_content ; html_content }

Which seems to do what I want, but it’s a pretty clunky interface:

let email = Email.make ~content:(`Plain_and_html { Email.plain ; html }) ()

I’m just wondering if other people have run into cases like this and what your preferred solution is?

How about:

(* email.ml *)

let plain ?html text = {
  plain_content = Some text;
  html_content = html;
}

let html ?plain text = {
  plain_content = plain;
  html_content = text;
}
3 Likes

This is just as clunky as the third option you used, but reading the description made me think of the Either monad. So i’d probably model it like that. The poly variant instead of the record is just so i don’t need to create a temporary record that won’t really be used anywhere else. I suppose they can also sort of act like a “tag” to possibly avoid getting the order wrong (instead of using plain strings)

type email =
  { plain_content : string option
  ; html_content : string option
  }

let make ~content () =
  let plain_content, html_content =
    match content with
    | `First (`Plain plain) -> Some plain, None
    | `First (`Html html) -> None, Some html
    | `Second (`Plain plain, `Html html) -> Some plain, Some html
  in
  { plain_content; html_content }
;;

let _ = make ~content:(`First (`Plain "plain")) ();;
let _ = make ~content:(`Second (`Plain "plain", `Html "html")) ();;

This seems like an OK interface to me (and is just slightly rephrased version of @anuragsoni’s suggestions, to dispense with the polymorphic variants):

type t = {html: string option; plain: string option}

type email_content =
  | Html  of string
  | Plain of string
  | Both  of {html: string; plain: string}

let make : email_content -> t = function
  | Html html          -> {plain = None      ; html = Some html}
  | Plain plain        -> {plain = Some plain; html = None}
  | Both {html; plain} -> {plain = Some plain; html = Some html}

(******)

let html = ""
let plain = ""

let html_email  = make (Html html)
let plain_email = make (Plain plain)
let combo_email = make (Both {html; plain})

I think this datatype is a special case of what Haskeller’s call These

type ('a, 'b) these =
  | This  of 'a
  | That  of 'b
  | These of 'a * 'b
2 Likes

Why not keep it simple and just use:

type t =
  { plain_content: string;
    html_content: string }

(since you still have to check whether the strings are empty or not, the option only adds noise).

Cheers,
Nicolás

3 Likes

The version where you wrap the two with variants is interesting:

let make ~content:(`Plain_and_html ((`Html "html"), (`Plain "plain")))

It does provide the type safety I was looking for, although it’s not really any less clunky than the record version (it’s still interesting though).

Removing the option isn’t an option, since empty body segments are different from non-existent ones and a default of adding an empty body segment would be very confusing (for example make ~plain_content:"" ~html_content:"some long string" () would be displayed as a blank email in most modern email clients). We also don’t want to disallow empty bodies if people are sending an empty email on purpose though.

I’m tempted to add two versions like @yawaramin suggested, but it feels kind of awkward. It might be worth having make_plain ~plain_content ?html_content () and make_html ~html_content ?plain_content () though. I’ll have to consider it.

Thanks for the ideas everyone!

You can use private types to enforce these kinds of invariants:

module Email : sig
  type t = private {html: string option; plain: string option}
  val plain: string -> t
  val html: string -> t
  val both: string -> string -> t
end = struct
  type t = {html: string option; plain: string option}
  let plain plain = {html = None; plain = Some plain}
  let html html = {html = Some html; plain = None}
  let both html plain = {html = Some html; plain = Some plain}
end
open Email

(* You can match Email.t *)
let display email =
  match email with
  | { html = Some html; _ } -> display_html html
  | { html = None; plain = Some plain } -> display_plain plain
  | { html = None; plain = None } -> assert false (* You still need this though *)

Because the type is private you cannot construct it outside the module:

let email = { html = None; plain = None}

Cannot create values of the private type Email.t