Modern JavaScript Web Development Cookbook
上QQ阅读APP看书,第一时间看更新

Opaque types for safer coding

In Flow (and TypeScript as well), types that are structurally the same are considered to be compatible and one can be used instead of the other. Let's consider an example. In Uruguay, there is a national identification card with a DNI code: this is a string that's formed by seven digits, a dash, and a check digit. You could have an application that lets you update people's data:

// Source file: src/opaque_types.js

type dniType = string;
type nameType = string;

function updateClient(id: number, dni: dniType, name: nameType) {
/*
Talk to some server
Update the DNI and name for the client with given id
*/
}

What could happen? If you don't define better types, there's nothing preventing you from doing a call such as updateClient(229, "Kari Nordmann", "1234567-8"); can you spot the switched values? Since both dniType and nameType are just bottom strings, even though they imply totally different concepts, Flow won't complain. Flow ensures that types are used correctly, but since it doesn't handle semantics, your code can still be obviously wrong.

Opaque types are different, since they obscure their internal implementation details from the outside, and have much stricter compatibility rules. You could have a file called opaque_types.js with the following definitions:

// Source file: src/opaque_types.js

opaque
type dniType = string;
type nameType = string; // not opaque!

Then, in a different source file, we could attempt the following:

// Source file: src/opaque_usage.js

import type { dniType, nameType } from "./opaque_types";
import { stringToDni } from "./opaque_types";

let newDni = "1234567-8"; // supposedly a DNI
let newName = "Kari Nordmann";

updateClient(229, newName, newDni); // doesn't work; 2nd argument should be a DNI
updateClient(229, newDni, newName); // doesn't work either; same reason

How can we fix this? Not even changing the definition of newDni would help:

let newDni: dniType = "1234567-8"; // a string cannot be assigned to DNI

Even after this change, Flow would still complain that a string isn't a DNI. When we work with opaque types, if we want to do type conversions, we must provide them on our own. In our case, we should add such a function to our file with type definitions:

// Source file: src/opaque_types.js

const stringToDni = (st: string): dniType => {
/*
do validations on st
if OK, return a dniType
if wrong, throw an error
*/
return (st: dniType);
};

export { stringToDni };

Now, we can work! Let's see the code: 

// Source file: src/opaque_usage.js

updateClient
(229, stringToDni(newDni), newName); // OK!

This is still not optimal. We know that all DNI values are strings, so we should be able to use them as such, right? This isn't the case:

// Source file: src/opaque_usage.js

function showText(st: string) {
console.log(`Important message: ${st}`);
}

let anotherDni: dniType = stringToDni("9876543-2");
showText(anotherDni); // error!

The anotherDni variable is of dniType, but as opaque types carry no information as to the real types, trying to use it as a string fails. You could, of course, write a dniToString() function, but that seems to be overkill—and would quickly get out of control in a system with potentially dozens of data types! We have a fallback: we can add a subtyping constraint, which will allow the opaque type to be used as a different type:

// Source file: src/opaque_types.js

opaque
type dniType : string = string;

This means that dniType may be used as string, but not vice versa. Using opaque types will add safety to your code, since more errors will be caught, but you can also get a certain measure of flexibility through these constraints, which will make your life easier.