Approaches to Type Safe JS Interop Design

Problem Statement

I am refactoring code base for Stencil to improve some existing features and to remove some technical debts that piled up over time. This leads to some interesting design decisions I have to make - choosing type safety and code ergonomic.

Let's understand the problem at hand first.

module Metadata = {
  type metadata = {
    @set
    "maskAngle": float,
  }

  @get @return(nullable) external metadata: t => option<metadata> = "metadata"
  @set external setMetadata: (t, metadata) => unit = "metadata"

  let getMaskAngle = (t: t): float => {
    switch metadata(t) {
    | Some(meta) if StdLib_FFI.hasOwnProperty(meta, "maskAngle") => meta["maskAngle"]
    | _ => 0.
    }
  }

  let setMaskAngle = (t: t, angle: float) => {
    switch metadata(t) {
    | None => ()
    | Some(meta) => meta["maskAngle"] = angle
    }
  }

The goal is very simple, some fields like maskAngle are introduced later. That means it needs to be handled properly to support backward compatibility. The current design handles this check using a function that works like a getter. It checks for the existence of the property and return a default value if the property is not found.

Writing the setter/getter for each property is very repetitive task and it creates a lot of boilerplate code for just this particular components. There are more components that are very similar to this.

I want to improve this original design because it is related to how the editor history works i.e. handling undo/redo.

Improving this code can:

  1. Improve and simplify the undo/redo history.
  2. Remove some boilerplate code that is very repetitive.
  3. Move to immutable object.

On point #3, the current design mutates the original object. In order to improve the undo/redo, the new design requires the object to be immutable because React's hooks don't play nicely with mutable object - mutating the object outside of setState won't trigger the effect.

I came up with a few approaches after spending a bit more time trying to solve the issue. The issue mostly lies on how to make the API type safe and ergonomic enough for me to use.

Approach #1 - Nullable Field

The most obvious way to approach this is to have the field handles null or undefined value. We can do this by wrapping the value with Js.Nullable.t.

type metadata = {
  maskAngle: Js.Nullable.t<float>
}

let default: metadata = {
  maskAngle: Js.Nullable.return(0.)
}

Why not option? Here's why.

When sharing object definition between Rescript and JS, I always opt for types that have 1-1 representation. We can avoid writing decoder for this object if we do it this way.

Whenever we need to use the field, we need to handle the null case explicitly. This approach is okay because I no longer needs an explicit setter/getter. This is handled by the record itself.

let nullableAlternative = (a: Js.Nullable.t<'a>, default: Js.Nullable.t<'a>): Js.Nullable.t<'a> => {
  Js.Nullable.isNullable(a) ? default : a
}

nullableAlternative(metadata.maskAngle, default.maskAngle)
->Js.Nullable.toOption
->Belt.Option.getWithDefault(0.) // here's the problem, the default is set twice

However, as you can see above the field is wrapped with Js.Nullable.t, it needs a default value if we were to "unwrap" this. I already have a default record but because this default record has the same type as the metadata, its corresponding field has Js.Nullable.t wrapper too. So I need to give another default value. As you can imagine, I need to do this everywhere where the field is used. Not fun.

Mistakes could happen because at one point I could specify a different default value, while at a different point I could specify a different default value.

Approach #2 - GADT and Existential Types

The second approach does away with Js.Nullable.t type. It is also more interesting because it shows the power of type system.

  type metadata = {
    src: string,
    mask: string,
    maskAngle: float,
  }

  let default = {
    src: "/static/images/image-placeholder.jpg",
    mask: "/static/images/editor/stroke-01.svg",
    maskAngle: 0.,
  }

  type rec property<'a> =
    | Src: property<string>
    | Mask: property<string>
    | MaskAngle: property<float>

  type rec someProperty = SomeProperty(property<'a>): someProperty

  let toString = (prop: someProperty) => {
    switch prop {
    | SomeProperty(Src) => "src"
    | SomeProperty(Mask) => "mask"
    | SomeProperty(MaskAngle) => "maskAngle"
    }
  }

  @get @return(nullable) external metadata: t => option<metadata> = "metadata"
  @set external setMetadata: (t, metadata) => unit = "metadata"

  @get_index @return(nullable) external js_getProperty: (metadata, string) => option<'a> = ""

  let getProperty = (metadata: metadata, prop: property<'a>): 'a => {
    let someProp = SomeProperty(prop)->toString

    switch js_getProperty(metadata, someProp) {
    | None =>
      // this is safe because we know `default` has the property
      js_getProperty(default, someProp)->Belt.Option.getUnsafe
    | Some(v) => v
    }
  }

  let _ = getProperty(default, Src)
  let _ = getProperty(default, MaskAngle)

The idea behind this approach is similar to what is usually done in JS where you'd usually try to dynamically get a property with the key assigned from a variable.

const key = "some_key"

console.log(object[key])

This is what we want to recreate but in a type safe manner as opposed to the JS world where the value returned can be anything. First thing we need to do is to create a binding that does this (unsafely).

 @get_index @return(nullable) external js_getProperty: (metadata, string) => option<'a> = ""

Note the return type - a polymorphic variable. This is not safe for us to use directly because if the value being returned doesn't match the type signature, the compiler would not complain.

let angle: string = js_getProperty(default, "maskAngle") // This still compiles despite this returns a float. 'a accepts anything

Surely this is something we want to avoid. We can fix this by creating a wrapper function that safely abstract this unsafe function.

  let getProperty = (metadata: metadata, prop: property<'a>): 'a => {..}

The idea here is to create a value (i.e. prop)  that has a type that matches the return type. We want a variant type (sum type) here as we want to pattern match on each variant, but ordinary variant can only return a single type.

type property =
  | Src
  | MaskAngle

Instead, we can use GADT as GADT works very well here because,

  1. We can create a data constructor that returns different type based on the polymorphic variable given.
  2. We still can pattern match and have exhaustive pattern matches warning.
  type rec property<'a> =
    | Src: property<string>
    | MaskAngle: property<float>

The prop argument we passed in acts as the property we will pass in to our js_getProperty function once we turn this property to its relevant property string in JS.

Additionally and the most important part is that because GADT allows you to specify the type for each constructor, it can act as our type witness for our return type. The polymorphic variable <'a> in property<'a> will determine the return type of our function. Great!

The only thing we need to do now is to stringify the GADT constructors into its corresponding property in JS. Here's our next problem lies,

  let toString = (prop: property<'a>) : string => {
    switch prop {
    | Src => "src"
    | Mask => "mask"
    | MaskAngle => "maskAngle"
    }
  }

Remember that GADT allows us to specify the type for each of the constructors. Mask here has type property<string> while MaskAngle has type property<float>. Well, they don't matched.

We need a way to tell the compiler to treat them as one type. Here we can use another technique with existential types.

  type rec someProperty = SomeProperty(property<'a>): someProperty

This allows you to "forget" the polymorphic variable with some restrictions. We can wrap our property with SomeProperty constructor before passing in to the toString function. Now the compiler is happy because they are finally the same type.

let toString = (prop: someProperty) => {
  switch prop {
  | SomeProperty(Src) => "src"
  | SomeProperty(Mask) => "mask"
  | SomeProperty(MaskAngle) => "maskAngle"
  }
}
  
let someProp = SomeProperty(prop)->toString

To get the our stringified property, the toString function can pattern match on the propery and return the proper string value.

Existential type works here because we are only pattern matching on the constructors and not doing anything fancy with it. For example, if you're trying to return this polymorphic variable for the caller function to consume then it will thrown an error as the type variable is trying to escape its scope which is not allowed. See example below for concrete example of this.

  type property<'a> = {field: 'a}
  type rec someProperty = SomeProperty(property<'a>): someProperty

  let x: property<string> = {field: "hello"}

  let _ = () => {
    let someProp = SomeProperty(x)

    switch someProp {
    | SomeProperty(prop) => prop.field
    }
  }

Trying to return the value will lead to an error because the type will escape from its scope.

  77 ┆
  78 ┆   switch someProp {
  79 ┆   | SomeProperty(prop) => prop.field
  80 ┆   }
  81 ┆ }

  This has type: \"$SomeProperty_'a"
  Somewhere wanted: 'a
  The type constructor $SomeProperty_'a would escape its scope

I think this is a great approach to handle JS dynamic type system while at the same time providing a type safe API.

The only thing I don't like with this approach is that it is still possible to get a runtime error when the record is accessed directly without using the getter function.

// this could return undefined if the metadata doesn't have this field but our type is a float!
let _ = metadata.maskAngle 

You need to exclusively access the prop using the function and there's no way to restrict access through record accessors.

Side note on GADT

The same technique can be done without GADT if you want to use variant. This uses phantom variable to carry the type witness. Haskell uses this a lot with Proxy type.

type proxy<'a> = Proxy

type property =
  | Src
  | MaskAngle

let getProperty = (metadata: metadata, prop: property, proxy: proxy<'a>): 'a => {..}

// usage
let stringProxy: proxy<string> = Proxy

getProperty(metadata, Src, stringProxy)

Of course, you'd need to find a way to make sure that Src only can be used with proxy<string>.

With GADT, this can be concisely done. It's just worth mentioning here that alternative approach exists.

Approach #3 - Decoder

Another approach is to write a JSON decoder where each backward-compatible field needs to be decoded manually.

type metadata = {
  src: string,
  mask: string,
  maskAngle: float,
}

let default = {
  src: "/static/images/image-placeholder.jpg",
  mask: "/static/images/editor/stroke-01.svg",
  maskAngle: 0.,
}

@get @return(nullable) external js_metadata: t => option<metadata> = "metadata"
@set external setMetadata: (t, metadata) => unit = "metadata"

let metadata = (obj: t) => {
  let meta = js_metadata(obj)->Belt.Option.getWithDefault(default)
  let json = Obj.magic(meta) // it's fine because it is essentially same

  // Handle backward compatibility
  let decode = () => {
    ...meta,
    maskAngle: Aeson.Decode.optionalField(
      "maskAngle",
      Aeson.Decode.float,
      json,
    )->Belt.Option.getWithDefault(0.),
  }

  switch decode() {
  | v => v
  | exception Aeson.Decode.DecodeError(_) => meta
  }
}

This approach uses some "unsafe" code like Obj.magic that basically tell the compiler that an apple is an orange. This is fine our case because the underneath object is the same. It's only needed here to avoid decoding the whole object that we can guarantee its structure.

The code is very concise and the best part is that you can use the record normally without having to abstract the getter/setter into different functions.

This is the approach that I settled with at the end.

Other consideration

I also thought about using Proxy. If you don't need to support IE, most modern browsers support this feature.

It allows you to write handlers for traps that you choose. One of the supported traps is get in which your handler function gets called when any of the object properties is accessed. But again, even with this approach it'd ended up like approach #1 or #2 in order to create a type safe API.

Anyway, that's all I have. I hope that you find this design exploration useful. Sometimes, it takes a few designs to come up with a design that you're satisfied with.