Elm's Opaque Types are a powerful tool for narrowing the surface area where you check a constraint. TypeScript's Branded Types give similar functionality but without closing outside use, so you can't be sure the constraints are enforced everywhere. Let's explore the differences to better understand why Elm's Opaque Types are such an important tool.
Strictly speaking, opaque types don't make Elm's type system more sound. Instead, they help you narrow your thinking of how a particular type is used to within a single module. They allow you to check that a constraint is enforced in a single module, rather than having to ensure that a constraint is enforced throughout your entire codebase, both now and in the future.
For example, if you want to ensure that a String actually represents a confirmed email address, that has nothing to do with type soundness - the type is just a String. But if the only way to get a value with type ConfirmedEmailAddress = ConfirmedEmailAddress String
is through an HTTP request to a specific server endpoint, then you can trust any value of that type after checking the ConfirmedEmailAddress
module and the API endpoint. You just need to make sure that you trust that server endpoint and the ConfirmedEmailAddress
module. It's the same idea as [Using elm types to prevent logging social security #'s].
module ConfirmedEmailAddress exposing (ConfirmedEmailAddress, checkEmailAddress)
type ConfirmedEmailAddress = ConfirmedEmailAddress String
checkEmailAddress : (Result Http.Error ConfirmedEmailAddress -> msg) -> String -> Cmd msg
checkEmailAddress toMsg emailAddress =
Http.post
{ url = "https://example.com/confirm-email-address.json?email="
++ Url.percentEncode emailAddress
, body = Http.emptyBody
, expect = Http.expectJson toMsg
(Json.Decode.string
|> Json.Decode.map ConfirmedEmailAddress
)
}
Compare this with Branded Types in [TypeScript].
type ConfirmedEmailAddress = string & { __brand: "ConfirmedEmailAddress" };
// uh oh, any code can brand it
const unconfirmedEmail = "unconfirmed-email@example.com" as (string & {
__brand: "ConfirmedEmailAddress");
};
So some drawbacks to Branded Types in TypeScript are:
Another example of a Branded Type in TypeScript is marking a type as representing a specific currency.
type Usd = number & { __brand: "USD" };
function fromCents(cents: number): Usd {
return cents as Usd;
}
This Usd
type allows us to brand a number so we know it represents US Dollars. That's great because we want to:
For point 2, we want to make sure that there is a single place that builds up currency. For example, we don't want someone to accidentally use dollars as a float somewhere. But since a Branded Type in TypeScript is "open" and uses casting to create it, there is no single place that we can enforce as the only place the logic for creating and dealing with that type. So any outside code can brand it like this:
// whoops, (fromCents(150) === 150), (fromCents(150) !== 1.5)
const aDollarFifty = 1.5 as number & { __brand: "USD" };
Compare that with an Opaque Type in Elm.
module Money exposing (Money, Usd)
type Money currency = Money Int
type Usd = Usd
fromUsDollars : Int -> Money Usd
fromUsDollars dollarAmount = Usd (dollarAmount * 100)
fromUsCents : Int -> Money Usd
fromUsCents usCents = Usd usCents
Our Elm Usd
type cannot be created outside of that module. If we want to see how that type is being used, we only have one place to look: within the Money
module where it's defined. Since it isn't exposed to the outside world, we know that we've limited the possible ways that outside code can use that type.
The technique described above is the idiomatic approach to branded types in TypeScript (used in the official TypeScript examples and in the TypeScript codebase). There is another technique that allows you to provide unique brands that are enclosed within a given scope using Unique Symbols.
module Email {
declare const confirmedEmail_: unique symbol;
type ConfirmedEmail = string & {[confirmedEmail_]: true};
export function fromServer(emailAddress: string): ConfirmedEmail {
// validate email address
return emailAddress as ConfirmedEmail;
}
}
const unconfirmedEmail = "unconfirmed-email@example.com" as // ??? there's no exported type to use here
This technique succeeds in ensuring that the ConfirmedEmail
type cannot be constructed outside of the scope of Email
(assuming you don't use any
types of course).
However, now we have no exported type to use to annotate values to ensure that the correct type is used. That means we can't write code like this outside of the scope of Email
:
function sendEmail(email: Email.ConfirmedEmail) {
// ...
}
You could certainly implement sendEmail
within the scope of Email
. But I think being able to annotate values is an important feature that is likely to become a roadblock when we want to ensure we receive our unique branded type as a parameter somewhere outside of Email
.
We could export
the ConfirmedEmail
type to outside of the Email
module, but then that gets us back at the initial challenge with branded types: the type can be used to cast a value that is constructed anywhere in our codebase.
const unconfirmedEmail =
"unconfirmed-email@example.com" as Email.ConfirmedEmail;
The TypeScript language could have a specific feature for opaque types (like Flow's Opaque Type Aliases), but it seems that they plan to stick with the current branded types approach as the recommended solution.
Opaque Types in Elm are a powerful tool to let you narrow the scope of code you need to think about to make sure you've gotten your constraints right.
Sign up to get my latest Elm posts and course notifications in your inbox.
Pure Elm content. Unsubscribe any time.
TypeScript's Blind Spots
It's hard to know when to trust TypeScript for Elm developers who are used to a sound type system. It helps to know where its blind spots are.
TypeScript's Blind Spots
It's hard to know when to trust TypeScript for Elm developers who are used to a sound type system. It helps to know where its blind spots are.
TypeScript
Using elm types to prevent logging social security #'s
One of the most successful techniques I've seen for making sure you don't break elm code the next time you touch it is a technique I call an Exit Gatekeeper.