Stronger JavaScript with Opaque Types

How opaque types in Flow and TypeScript can make your code better, faster, more secure and easier to refactor.

@author Charles Pick
@date December 06, 2017

What’s an opaque type?

In Flow and TypeScript, types are transparent by default - if two types are structurally identical they are deemed to be compatible. For example, the following types are compatible:

type Username = string
type Password = string

Username and Password are both strings, so they are equivalent as far as Flow and TypeScript are concerned, even though they represent entirely different concepts. Transparent types can ensure type safety, but they don’t encode any information about the program semantics. A fully type checked program can still be completely wrong in very horrible ways, consider this example, you have 2 files:

accounting.js:

export type AccountNumber = number
export type AccountBalance = number
export type PaymentAmount = number

type Account = {
  accountNumber: AccountNumber,
  balance: AccountBalance,
}

export function spend(accountNumber: AccountNumber, amount: PaymentAmount) {
  const account = getAccount(accountNumber)
  account.balance -= amount
}

and handleRequest.js:

import { spend } from "./accounting"

type Request = {
  body: {
    accountNumber: number,
    amount: number,
  },
}

export default function handleRequest(req: Request) {
  const { accountNumber, amount } = req.body
  spend(amount, accountNumber)
}

Did you spot the error? We mixed up our parameter order when calling spend(), and we’ve accidentally sent someone’s money to the wrong person. Oops.

Flow and TypeScript will happily accept this program because accountNumber and amount are both just numbers and thus interchangeable, but this program is very wrong and the failure mode is catastrophic.

Opaque types are different, they hide their internal details from the outside world and are only compatible when used explicitly. They give developers a way to encode semantic information in their programs that can be statically verified by the type checker. Opaque types are supported in Flow using the opaque keyword. Let’s rewrite accounting.js to use it:

// @flow
export opaque type AccountNumber = number
export opaque type AccountBalance = number
export opaque type PaymentAmount = number

type Account = {
  accountNumber: AccountNumber,
  balance: AccountBalance,
}

export function spend(accountNumber: AccountNumber, amount: PaymentAmount) {
  const account = getAccount(accountNumber)
  account.balance -= amount
}

Now when we run Flow we get a couple of errors:

Error: example-2/handleRequest.js:13
 13:   spend(amount, accountNumber);
             ^^^^^^ number. This type is incompatible with the expected param type of
 12: export function spend(accountNumber: AccountNumber, amount: PaymentAmount) {
                                          ^^^^^^^^^^^^^ AccountNumber. See: example-2/accounting.js:12

Error: example-2/handleRequest.js:13
 13:   spend(amount, accountNumber);
                     ^^^^^^^^^^^^^ number. This type is incompatible with the expected param type of
 12: export function spend(accountNumber: AccountNumber, amount: PaymentAmount) {
                                                                 ^^^^^^^^^^^^^ PaymentAmount. See: example-2/accounting.js:12


Found 2 errors

This happens because opaque types must be explicitly created, we can no longer just pass any old number into spend(), we need some way to create an AccountNumber or PaymentAmount as appropriate. To do this, we’ll add two more functions to accounting.js:

export function validateAccountNumber(input: number): AccountNumber {
  if (Math.floor(input) !== input) {
    throw new Error("Invalid account number, must be an integer!")
  }
  return input
}

export function validatePaymentAmount(input: number): PaymentAmount {
  if (typeof input !== "number" || isNaN(input)) {
    throw new Error("PaymentAmount must be a valid number")
  }
  if (input <= 0) {
    throw new Error("Can't pay zero or less!")
  }
  return input
}

These functions take a number and return an AccountNumber and PaymentAmount respectively. They are the only way to create these types in the program.

This has some important benefits. It means that every AccountNumber and every PaymentAmount in the application has been validated, and it’s no longer possible to use them interchangeably by mistake. We’ve also eliminated a glaring bug where if the user specified a negative payment amount their balance would be incremented instead of decremented - we can be 100% confident that every reference to PaymentAmount in our program will be a positive number.

Let’s update handleRequest.js to use these validators:

import {
  spend,
  validateAccountNumber,
  validatePaymentAmount,
} from "./accounting"

type Request = {
  body: {
    accountNumber: number,
    amount: number,
  },
}

export default function handleRequest(req: Request) {
  const accountNumber = validateAccountNumber(req.body.accountNumber)
  const amount = validatePaymentAmount(req.body.amount)
  spend(amount, accountNumber)
}

Now when we run Flow, it shows us that we mixed up the parameter order in spend():

Error: example-3/handleRequest.js:19
 19:   spend(amount, accountNumber);
             ^^^^^^ number. This type is incompatible with the expected param type of
 12: export function spend(accountNumber: AccountNumber, amount: PaymentAmount) {
                                          ^^^^^^^^^^^^^ AccountNumber. See: example-3/accounting.js:12

Error: example-3/handleRequest.js:19
 19:   spend(amount, accountNumber);
                     ^^^^^^^^^^^^^ number. This type is incompatible with the expected param type of
 12: export function spend(accountNumber: AccountNumber, amount: PaymentAmount) {
                                                                 ^^^^^^^^^^^^^ PaymentAmount. See: example-3/accounting.js:12

This also means that Flow will now catch accidental operations that make no sense but are otherwise type safe, such as adding a value to an account number:

function incrementAccountNumber(accountNumber: AccountNumber) {
  return accountNumber + 123
}

Running Flow produces:

Error: example-5/handleRequest.js:23
 23:   return accountNumber + 123;
              ^^^^^^^^^^^^^ AccountNumber. This type cannot be added to
 23:   return accountNumber + 123;
                              ^^^ number


Found 1 error

This is really cool and can catch a lot of bugs, but there are cases where we do need to be able to treat an opaquely typed value as normal, if we needed to calculate a percentage of the payment amount for example. To do this with Flow we add a Subtyping Constraint to our definition of PaymentAmount:

export opaque type PaymentAmount: number = number

All of our previous guarantees still hold - PaymentAmount must always be a positive number, because it can only ever be created by validatePaymentAmount(), but now the rest of the code can treat it like any other number, so the following is now valid:

function calculateProfit(amount: PaymentAmount) {
  return amount / 10
}

Subtyping Constraints are particularly useful when working with objects with private / internal properties - opaque types ensure that consumers can only ever access the public interface. In the following example we declare a User type which has a private password field which can only be accessed from within the defining file. Attempts to reference user.password anywhere else in the program will cause a Flow error:

export opaque type User: { id: number, username: string } = {
  id: number,
  username: string,
  password: string,
}

What about TypeScript?

TypeScript does not have built-in support for opaque types, however it is possible to approximate them using intersections:

type AccountNumber = number & { _: "AccountNumber" }

function validateAccountNumber(input: number) {
  if (Math.floor(input) !== input) {
    throw new Error("Invalid account number, must be an integer!")
  }
  return input as AccountNumber
}

function logAccountNumber(input: AccountNumber) {
  console.log(input)
}

// Now, the following will fail:
logAccountNumber(123)

// But this will work correctly:
logAccountNumber(validateAccountNumber(123))

You can try this example in the TypeScript Playground.

In the above we’re defining a fake property called _ on AccountNumber (there’s nothing special about the property name, it could be anything), we then explicitly cast our input to AccountNumber at the end of our validator. Since this is the only way to produce an AccountNumber, we can guarantee that all values of that type will be valid.

We can make this easier with a helper type:

type Opaque<K, T> = T & { __TYPE__: K }

Which is used like this:

type AccountNumber = Opaque<"AccountNumber", number>

Subtyping Constraints are a little harder to emulate cleanly, though it is possible:

type Opaque<K, T> = T & { __TYPE__: K }

// This is our publicly visible interface
export type User = Opaque<
  "User",
  {
    id: number
    username: string
  }
>

// This is our private, internal only interface
type $User = User & {
  password: string
}

export function makeUser(input: {
  id: number
  username: string
  password: string
}) {
  // Ugly nested type cast:
  return input as $User as User
}

function greetUser(user: User) {
  console.log("hello", user.username)
}

function hashPassword(user: User) {
  // we must cast to our internal type before we can access the private fields.
  const u = user as $User
  return u.password.length
}

This is a bit more work and requires some discipline on the part of the developer, but still provides many of the same guarantees as Flow - User can only be created by makeUser() and only code in the defining file can access internal fields (as long as we don’t export our $User private type):

const input = { id: 123, username: "bob", password: "hunter2" }
const bob = makeUser(input)

input === bob // true!

greetUser(input) // error, input is not a User.
greetUser(bob) // works, bob is a real user

hashPassword(input) // fails, input is not a User.
hashPassword(bob) // works

Refactoring & Debugging

Aside from important guarantees about validation, one of the main benefits of using opaque types is that it becomes very easy to trace the flow of certain values throughout your application. A username is no longer just a string, it’s a specific kind of value that has a semantic meaning. With opaque types it becomes trivial to find every use of Username in your program, which makes both refactoring and debugging much safer and easier.

To take a fairly common class of bug as an example, perhaps a Username is being accidentally truncated somewhere. Finding the cause of this kind of problem can ordinarily be pretty time consuming, there are likely lots and lots of places in your code that call .slice(). With opaque types your IDE (or Flow) can tell you all the places that Username is referenced, cutting down debug time significantly.

Leaner Tests, Faster Code

One of the nicest things about opaque types is that they greatly cut down the number of tests and runtime checks you need to write. It’s no longer necessary for the spend() example above to check that PaymentAmount is valid; it’s guaranteed to be valid throughout the program. We no longer need to write tests to ensure that spend() behaves correctly when given invalid values because our type system guarantees that those scenarios can never happen. This means we can focus our energy on testing where it matters (in validatePaymentAmount() in this example), and it saves us from writing the same error prone checks every time we encounter a PaymentAmount in our code. This turns out to be a boost for performance too, it means we ship less code to the client and means we’re not pointlessly rechecking the validity of a value just in case it has been mutated - the type system ensures that cannot ever happen.

Conclusion

Opaque types are particularly important for anything involving user input, as they make it possible to statically prove that all input is validated before it is used. This feature alone should be enough to make you consider Flow or TypeScript if you haven’t yet adopted static typing.

If you’re already using Flow, I’d definitely recommend giving opaque types a try. In fact I’d go so far as to say that almost all of your types should be opaque. Switching to opaque by default, and only exposing the properties and methods that you really need to will make your code much more robust, yet easier to change. For TypeScript it’s a bit more work without language support, but it’s still achievable and the benefits are so clear I’d expect that support to land sometime in the near future.

codemix

the TypeScript experts.
email hello@codemix.com, or call us on (+44) 01347 833 119


Send Us A Message