DEV Community

loading...

Reusing record fields across types in Reason

johnridesabike profile image John Jackson Updated on ・5 min read

When you’re used to a dynamic language like JavaScript, one of the biggest mental shifts you face in Reason is just how strict its type system is. In TypeScript, you may be inclined to write code like this:

interface dog {
    name: string;
    species: "dog";
    goodBoy: boolean
}
interface cat {
    name: string;
    species: "cat";
    naps: boolean
}
const fido: dog = { name: "Fido", species: "dog", goodBoy: true };
const mittens: cat = { name: "Mittens", species: "cat", naps: true };
const setName = <T extends {name: string}>(pet: T, name: string): T => ({ ...pet, ...{ name } });
const toto = setName(fido, "Toto");
const frodo = setName(mittens, "Frodo");

With the setName function, we’re telling the compiler, “I don’t care what type pet is as long as it’s a type that has a name property.” This seems perfectly logical, and, for TypeScript, it’s fine.

But Reason doesn’t like this logic at all. Let’s see what happens when we when try something similar in Reason:

type species =
  | Dog
  | Cat;
type goodBoy =
  | GoodBoy; /* They are all good boys. */
type dog = {
  name: string,
  species,
  goodBoy,
};
type cat = {
  name: string,
  species,
  naps: bool,
};
let fido = {name: "Fido", species: Dog, goodBoy: GoodBoy};
let mittens = {name: "Mittens", species: Cat, naps: true};
let setName = (pet, name) => {...pet, name};
let toto = setName(fido, "Toto");
/*                 ^^^^         
 ERROR: This expression has type dog but an expression was expected of type cat
*/
let frodo = setName(mittens, "Frodo");

TypeScript interfaces use structural typing, but Reason records use nominal typing. Our Reason setName function can either accept a cat or a dog type, not “any type that has a name field.” Unlike TypeScript, there’s no amount of type annotations we can write that will convince it to accept both.

(It’s also worth noting that reusing field names at all is usually discouraged in Reason. It forces the compiler to look for other clues to disambiguate types and may require you to annotate the types manually. If you must reuse field names, then it’s good practice to namespace them in their own modules.)

Solution A: Variants

In TypeScript, you can also use a union type, which is a bit stricter. Not just any value with the name field can be used, but only a dog or a cat.

const setName = <T extends dog | cat>(pet: T, name: string): T => ({ ...pet, ...{ name: name } });

Reason doesn’t have union types, but it has variants, which are kind of the same thing (but not really). Here’s how we might rewrite our Reason code to support that:

type goodBoy =
  | GoodBoy; /* They are never not good boys. */
type dog = {
  name: string,
  goodBoy,
};
type cat = {
  name: string,
  naps: bool,
};
/* species is a variant. */
type species =
  | Dog(dog)
  | Cat(cat);
let fido = Dog({name: "Fido", goodBoy: GoodBoy});
let mittens = Cat({name: "Mittens", naps: true});
let setName = (pet, name) => 
  switch (pet) {
  | Dog(dog) => Dog({...dog, name})
  | Cat(cat) => Cat({...cat, name})
  };
let toto = setName(fido, "Toto");
let frodo = setName(mittens, "Frodo");

This code is an improvement, and, most of the time, this is the style you’ll want to use in this situation. Variants keep your code organized and extremely safe.

Should we always use variants?

Variants are powerful, but they’re not always the right tool for the job. Here are some reasons why you wouldn’t want to use them:

JavaScript interoperability. The runtime representation of variants won’t translate to regular JavaScript. If you need to pass fido to some part of your program outside of Reason, then you’re going to have to extract him from the Dog constructor.

Performance. Unlike TypeScript unions, variants are tagged unions. The “tag” means that the compiler encodes some extra information in them to keep track of what type they are. If your switch statement ends up on a hot path, that can cause a performance decrease.

Complexity. Once you put fido or mittens in that species constructor, then the compiler will always treat them like either a Dog or a Cat. Every function you use with them will have to handle a different code path for one or the other. This means that you’ll be writing a lot of switch statements, even for basic operations like setting or getting their names.

And maybe most of your code deals only with a dog type, or only a cat type, and handling both species all the time is burdensome.

Solution B: Composing record types

We can avoid excessive use of variants by composing our records. We’ll take the fields common to both cat and dog and put them in a generic animal type. Then we’ll give the animal type a type parameter that lets us compose it with another type specific to each species.

type goodBoy =
  | GoodBoy; /* They are ALL good boys. */
type animal('species) = {
  /* 'species is a type parameter. */
  name: string,
  data: 'species,
};
type dog = {goodBoy};
type cat = {naps: bool};

let fido = {
  name: "Fido",
  data: {
    goodBoy: GoodBoy,
  },
};
let mittens = {
  name: "Mittens",
  data: {
    naps: true,
  },
};

let setName = (pet, name) => {...pet, name};
let toto = setName(fido, "Toto");
let frodo = setName(mittens, "Frodo");

Ta-da! If you look at setName’s inferred type, it’s (animal('a), string) => animal('a). The 'a type parameter is analogous to a TypeScript generic type. Because the function never has to actually do anything with that type, it doesn’t care what it is.

The compiler infers toto as type animal(dog) and mittens as type animal(cat), so either one is compatible with setName.

Keep in mind that you will probably still need to use variants in other parts of your code. Variants are extremely powerful and useful, and you shouldn’t avoid them. This technique doesn’t replace them, but it can help you write simple functions like setName more efficiently.

Solution C: Objects

If you’re used to code with structural typing everywhere, then losing the ability to use it may feel like a big deal. Why doesn’t Reason support it? Well, it does, but only in certain parts of the language.

Objects in Reason do not require any type declarations and can be used very flexibly. You could define fido as an object like this:

let fido = {as _; val name = "Fido"; val goodBoy = GoodBoy};

And now fido can be passed to any function that accepts any object with a name property.

Important caveat: Reason objects do not compile to JavaScript objects! But never fear, BuckleScript has its own version of objects that bridge the gap between the languages.

let fido = {"name": "Fido", "goodBoy": GoodBoy};

The syntax looks very similar, but the quotation marks around the property names means this is a special BuckleScript object. It compiles to a JavaScript object, and Reason can infer its type structurally.

Even with this, objects still have downsides. The syntax can be awkward, and you can’t do immutable updates like {...pet, name} like you can with records. The flexible typing can be a curse as well as a blessing. If you take full advantage of it, you may end up with more complicated code and unexpected error messages. Most of the time, you’ll probably want to stick to using records.

Conclusion

Reason is strict, yet not without plenty of tools we can use. Like most real-world problems, there’s rarely a “right” solution to how you structure your data.

I hope this has helped you figure out the technique right for you. Once you fully take advantage of Reason's type system, your code can reach the balance between strictness and flexibility, while always staying 100% type-safe.

Discussion

pic
Editor guide
Collapse
austindd profile image
Austin

Very nice write-up! This was indeed a pain point for me when I started programming in Reason. Today, I often use each of these techniques.

I'm sure you left out GADT's for a reason, but it might be worth mentioning that GADT's offer a solution to the "each function must handle all variants" issue with variants. Just an idea :)

Collapse
sophiabrandt profile image
Sophia Brandt

Thanks! That's a stumbling point for people new to Reason.
I wish I have had this post when I started using Reason.