How to model complex JavaScript objects?

I am binding to a function that has arguments that are potentially complex objects.

For context, I am binding to the find function on a MongoDb database collection which has the signature:

find(query, options)

The query argument can be a complex object.

Examples:

{ status: "A" }
{ status: { $in: [ "A", "D" ] } }
{ quantity: { $lt: 30 } }
{ $or: [ { status: "A" }, { qty: { $lt: 30 } } ] }

The options argument is an object that has a discrete set of properties, but the properties have different types.

Examples:

{ skip: 20 }
{ skip: 20, limit: 1000 }
{ sort: [ [ 'a', 1 ] ] }
{ projection: { 'a': 1, 'b': 1 }  }

I am assuming it might be easier to sacrifice type safety of these arguments for a simpler implementation, but I am unsure how to get ReScript to generate these objects.

How would I start modelling an external function like this one?

Thanks for your help.

Update: I’ve looked into the @glennsl/bs-json library and it looks like it can help with creating these complex objects.

For example, this code:

let _ = {
  open Json.Encode
  object_(list{
    (
      "$or",
      jsonArray([
        object_(list{("status", string("A"))}),
        object_(list{("qty", object_(list{("$lt", int(30))}))}),
      ]),
    ),
  })
}

Constructs an object with this shape:

{ $or: [ { status: "A" }, { qty: { $lt: 30 } } ] }

I’m planning to continue with this approach unless there is another method that might be better to use?

MongoDB is highly dynamic… i don’t think you will be very happy manually encoding / decoding all the results every time you need to do another request to the database.

Probably just better to type the query parameter as a generic Js.t object instead, or in case you want to have some static analysis, abstract commonly used queries into factory functions and hide the Query.t in an abstract type.

// Query.res
type t = Js.t({.}); // You can hide the concrete impl of t with a Query.resi file as well

let status = (status: string) => {
  { "status": status }
}
// App.res

module MongoDB = {
  @module("mongodb")  external find: (Query.t, Js.t('opts)) => Js.t('ret) = "find"
}

let query = Query.status("A");
let options = { "skip": 20 };

let someObj = MongoDB.find(query, options); 

Something like that.

For your type issue reagarding array with multiple values of different types, might be worth having a look at an old blog post about expressing “union types” in ReScript. It’s not the most elegant thing, because it’s using some pretty advanced type system features, and usually I’d recommend not trying to use arrays that mix different value types.

2 Likes

@ryyppy many thanks for the pragmatic solution, exactly what I was hoping for :pray:

Note that this example wouldn’t work in an array, as in the the example. The reason for that is that when you structurally type it to an object with a status field, an object with a qty field will not be possible in the array (because it’s not the same type). A solution is to make an opaque type and use the following to convert:

type status;
external toStatus: 'a => status = "%identity";
let status1 = {"status": "A"}->toStatus;
let status2 = {"qty": {"lt": 30}}->toStatus;
// array syntax for the experimental syntax is just []
let arr = [|status1, status2|];
1 Like

@jfrolich thanks for picking that up :pray:

Documenting what I’ve got so far:

module Query = {
  type exp // Type for a query "expression"
  external exp: 'a => exp = "%identity"
}

let query: {..} = %obj(
  {
    "$or": [Query.exp({status: "A"}), Query.exp({qty: {"$lt": 30}})],
  }
)

Which produces:

var query = {
  $or: [
    {
      status: "A"
    },
    {
      qty: {
        $lt: 30
      }
    }
  ]
};

Side note; sometimes an empty object is needed for a query, and in that case I intend to use:

let query: {..} = Js.Obj.empty()

Which produces:

var query = {};

Yep! :+1: You can make the types more strict, for instance using records in some places or functions to construct expressions. But that is refinement :slight_smile: .

1 Like

There’s a bit of runtime there, but we’re starting to use this: https://gist.github.com/bloodyowl/448e04f473bee1c5fcdb6f080e276add

4 Likes

Looks great. People sometimes are obsessed with 0-cost bindings. It really doesn’t make a difference for 99% of the bindings, so if the binding can be nicer with some runtime overhead it’s usually ok! Especially in this case if it’s a binding to a database, these things are not called in a tight loop, and the call itself results in io, so not CPU limited.

4 Likes