Use GenType with opaque types or "look-alike" types I define?

I’m trying to get my head around gentype and using it to get some type-safety around interop with TypeScript. I don’t know if I’ll use ReScript from TypeScript or vice-versa; I’m using gentype to generate compiler warnings if my bindings aren’t correct. My main question is whether it is safe/ok/recommended to use look-alike-subset ReScript types or whether I should always be using opaque types.

Here is the opaque-type way. There is no type-safety on the @get and @send methods - bad. If I change the binding name from “forEach” to “zebra” no compile errors are generated but at runtime it will fail. If the schedule function expects an int rather than a Js.Date.t, I’ll get a compile error which is good.

@genType.import("firebase/firestore") @genType.as("QuerySnapshot")
type t<'a>

@get external getSize: t<'a> => int = "size"
@get external getEmpty: t<'a> => bool = "empty"
@get external getDocs: t<'a> => array<queryDocSnapshot<'a>> = "docs"
@send external forEach: (t<'a>, queryDocSnapshot<'a> => unit) => unit = "forEach"

@genType.import("firebase/firestore")
external schedule: (t<'a>, Js.Date.t) => unit = "schedule"

Here is a simpler and easier way to do it. I define a type that looks like the type I’d receive from firebase. I can access the fields of this type directly without accessors. If the getSnapshot function returns something that looks different than the type I’ve defined I’ll get a compiler error. The real querySnapshot type has many more fields and methods than I’ve listed. I only define the ones I care about and use. The real one is a class not a simple object.

type querySnapshot<'a> = {
  size: string,
  empty: bool,
  docs: array<queryDocSnapshot<'a>>,
  forEach: (queryDocSnapshot<'a> => unit) => unit,
}

@genType.import("firebase/firestore")
external runQuery: unit => querySnapshot<'a> = "getSnapshot"

Assuming I never construct one of these types by myself - it is always generated by Firebase - is it ok to go with my simpler solution and define a look-alike type? I get nervous saying the type is x when in reality it is a lot more than just x and I’m only defining the small part I care about.

If I want to work with Firebase functions that receive a querySnapshot I assume my simpler solution here will fail because these functions will expect the full definition of the type (the opaque type), not just my small subset.

Is there a way to get type safety with accessors using an opaque type? There is no method I can @gentype.import to get the size of a querySnapshot for example.

I suppose I could define a small TypeScript file with accessor functions, and then use the opaque type and import those simple functions I’ve defined. That seems to cover all the bases and might actually be less error prone than the first option mentioned above.

I explored my last idea above - primarily use opaque types, create a small .ts file with just the functions I need, and gentype import those. For example, here is the TypeScript…

import { QueryDocumentSnapshot } from "firebase/firestore";

export const queryDocSnapshot = {
  id: (q: QueryDocumentSnapshot) => q.id,
  data: (q: QueryDocumentSnapshot) => q.data(),
  exists: (q: QueryDocumentSnapshot) => q.exists(),
};

And here is my ReScript…

@genType.import("firebase/firestore") @genType.as("QueryDocumentSnapshot")
type queryDocSnapshot

module QueryDocSnapshot = {
  type t = queryDocSnapshot

  @genType.import(("./firestore", "queryDocSnapshot.id"))
  external id: t => string = "id"

  @genType.import(("./firestore", "queryDocSnapshot.data"))
  external data: t => documentData = "data"

  @genType.import(("./firestore", "queryDocSnapshot.exists"))
  external exists: t => bool = "exists"
}

So basically every function I need in ReScript I create a tailored version of this in a small interop .ts file and then import those directly using @gentype. In this case my ReScript file is called Firestore.res and the interop file is called firestore.ts. This seems type-safe and not a lot of work. Definitely easier and safer to work with than using @get and @send.

I’m still curious how other people deal with this. Do you use opaque types? How do you ensure type-safety with accessors? Is it ok to use look-similar ReScript types?

Well for now I decided to use look-similar types rather than opaque types. It seems to work and is the least amount of code. The only gotcha I found had to do with currying. Below is the type I used with gentype. It did NOT work until I explicitly turned off currying for the two functions. Don’t know why. Everything compiled just fine both ways so I never would have known without testing. If I had used opaque types and wrote these functions in a helper file that problem wouldn’t have bitten me.

type queryDocSnapshot = {
  id: string,
  data: (. unit) => documentData,
  exists: (. unit) => bool,
}