Implementing getIn in Rescript: Recursively navigate nested objects

Hey there! I’m a big fan of Clojure and FP and found myself impressed with how Rescript may offer similar productivity without sacrificing type safety.

A tool I reach for a lot in my Clojure or JS code is https://clojuredocs.org/clojure.core/get-in or https://ramdajs.com/docs/#path as a more general way to reach an attribute in a nested object.

I took a swing at implementing it but found myself a bit stuck defining the types. I’ve been going through the docs and bouncing between Js.Array, Belt.Array, Js.Dict, and related modules but it’s not quite clicking yet. Is there a way to make it work? Is there a more idiomatic way to go about that in Rescript?

let data = {
  "a": {
    "b": {
      "c": "you found me",
    },
  },
}

let rec getIn = (~obj as o: {..}, ~keys as ks: array<string>, ~defaultValue as v=?) => {
 if Js.Array2.length(ks) > 0 || Belt.Array.get(ks, 0) == None {
   v
 }
 else {
   let x = Js.Dict.get(o, ks[0])

   switch x {
     | None => v
     | _ => x
   }
 }
}

Js.log(getIn(data, ["a", "b", "c"]))

There’s no type-safe way to implement it. In the typed FP world we would usually use lenses as the equivalent, but they require more work upfront. E.g. you’d need:

  • a lens to get the "a" prop value, e.g. getA
  • a lens to get the "b" prop value, e.g. getB
  • a lens to get the "c" prop value, e.g. getC

Then compose these lenses together, e.g. getIn(data, compose([getA, getB, getC])).

Here’s an article explaining how to implement lenses in OCaml: https://jobjo.github.io/2017/12/20/lenses-as-modules.html

Ah right! Lenses are the tool for that. In general, if you had to get data from 2 levels deep, what is the recommended way to get that data? Thanks.

If you know the path statically, then directly access the data using whatever access operators, as needed. If you know the path at runtime, then lenses.

Hello! Welcome.

This is a JavaScript object, not a hashmap (dict) You access it like data["a"]["b"]["c"] here. Check the output.

Data 2 level deeps shouldn’t use lenses. Nor 3 levels deep unless you literally have 3 dynamic data structures like 3 hash maps one inside another. But then you should restructure your data structure and access patterns.

Type safety isn’t about reusing your existing idioms with types; sometime it requires reshaping the data, from generic runtime bags of stuff into more domain-specific, statically known shapes. Consider that core.typed doesn’t help as much as folks would have liked because their code is still written in a dynamic way. That getIn helper isn’t gonna help you: consider that getIn(data, ["a", "c", "b"]) is still wrong at runtime, and only “safer” as in “I silenced the error at runtime but didn’t see/fix it”. In Rich Hickey’s words, you’d have complected the fact that the data might actually be optional, vs the fact that you made a typo at writing time.

You want that access to be statically correct. The helper doesn’t help and lenses don’t either if you want to compose 3+ nested hashmaps. The question to ask is “why am I composing 3+ nested dynamic data structures where I assume I know nothing about my domain”. Think about your use-case and you’ll find staticity; leverage that.

4 Likes

Thanks for the insight. I came here wanting to better understand how to think about and model problems in Rescript, unfortunately I only know what I already know which is where I got stuck when I couldn’t get the old and new to align :laughing:

Where is that Rich Hickey quote from? I don’t quite recognize it but I’d enjoy hearing the intended context.

That’s a good point. In my Clojure, I typically use get-in strictly for when data is optional like (get-in action [:meta :refresh] false) where not all actions of that type will require a refresh. Where as in the example I presented, it’s more about dealing with an unknown object which shouldn’t exist in Rescript.

Another great point, Rescript should understand the shape of data through a domain-focused context statically!

I can tell this is going to be a rough journey in the beginning but I really appreciate you taking the time to share insight about the differences in the approach, it makes a big difference. Thanks again!

Sorry I should have been more careful saying that. I was just using the word complected which he resurrected to talk about orthogonality. Though he also talked about optionality and orthogonality many times (core.spec).

Yeah stronger assumptions -> more statically known properties -> better typing & smaller runtime state space -> easier reading & debugging.

Indeed; we do differ a bit from Clojure*, and from many other FPs, through our focus of concreteness/specificity rather than the typical perspective of horizontally abstracting into reusable pieces like lenses. For example, our advice would instead be that you encapsulate a 3-layer hashmap access patterns into a single domain-specific getter and setter. Those would be 3-4 lines and reading that code tells you much more about what those 3 hashmaps actually represent, as opposed to lenses which would have only told you about the fact that you’ve got 3 hashmaps.

Consider also that hashmap should be your last resort of a data structure:

  • You don’t know about the nature of the amount of the keys (as opposed to a record/js object which has fixed fields), you just know it has some keys (or maybe not?).
  • You don’t know about the nature of the values (as opposed to a record/js object/tuple) beside that combined together they share a same broad type.
  • You don’t know about whether there are additional properties (ordering, 1-1, 1-many, key range & compactness, etc.).

It’s not better that you don’t know your use-case; it’s worse! YAGNI isn’t about rejecting cool concepts; it’s about concretization.

Np. Have fun =)

* Afaik Clojure doesn’t consider itself a FP, and from what I’ve heard, Rich Hickey’s perspective on this is more nuanced, but I digress.

2 Likes

That is another interesting aspect! I’ve appreciated designing systems around data > pure functions > side-effects > macros > classes and it seems like Rescript is on the same page. It’s become important to me to emphasize the domain and express the actual goals of the system. For instance describing systems like “A consumer user submitted a quote request, the vendor user submitted a bid, the consumer accepted, and now the request is contracted” is much better than “an async ajax request was appended to our event queue, the response was processed by a reducer, and the state tree was updated after hydrating the data”. The first version is something that can be understood by a domain expert to verify that the system does what’s intended, the second version is something likely only the developer that designed it fully understands. The original developer is also the only one who knows how the system relates to the actual purpose. I take it idiomatic Rescript will mitigate those situations to produce more meaningful, and valuable code?

Typically those would be my bread-and-butter but your points make sense, which explains why they’re not more emphasized in the docs. Sounds like Records > Objects > Map > Hashmap, Tuple > List > Array?

This is what feels frustrating as a Clojurist. There’s a bit of a persecution complex that develops as the main stream JS\Python\Java people look down on us for not being more popular and familiar. The other FP language users look down on us for not being FP enough, even other lispers look down on us for being the most popular lisp (like having a more famous, younger sibling) and the compromises required to support the JS and Java ecosystems. In my experience, it’s been a blast to use and I enjoy Clojure(Script) a lot, but it’s important and useful to learn other languages, especially ones with a different perspective on how to achieve similar goals like Rescript :slightly_smiling_face:

Looking at https://clojure.org/

Clojure is predominantly a functional programming language, and features a rich set of immutable, persistent data structures.

Is it fair to say that description also fits Rescript? Mostly we will write immutable data, pure functions, managed side-effects, and composed pipelines but there are some pragmatic escape-hatches for dealing with the imperfections of the real-world JS out there right?

One more question that came to mind this morning: Are there times where it makes sense to not know what data is from the Rescript perspective?

For instance in my JS and CLJS I’ve been building generic request handlers like:

(dispatch {:type :request
                 :payload {:url "/api/1.1/contracts"
                           :method :GET
                           :normalize-with :id}})

The part that processes it doesn’t really care what’s actually contained in the response, just needs to know about the payload params that describe how to make the request, and a hint about how to normalize it which suggests we’re expecting a list of results, and each item has an :id attr.

How would you design something like that in Rescript? Would you manually specify how each request is made? Or is there an idiomatic way to abstract it that lets you specify what’s in the response data when you are ready to consume and operate on it for domain specific features?

It’s simple to create a record type for the payload. As for the response, I assume the data is an array of JSON objects, i.e. type array<Js.Json.t>. Given that, it’s possible to write functions to process the JSON data and then later decode it into more strongly typed data. You may find this article interesting: https://lexi-lambda.github.io/blog/2020/01/19/no-dynamic-type-systems-are-not-inherently-more-open/

1 Like

Yeah about right but the trend of in-language macro systems like Zig and Jai do make us do a double take regarding doing macro better.

Yes but it’s about some middle ground. More importantly we’re not purists. The goal is to have a language for production, not tinkering. Afaik that was Rich Hickey’s intent too, but I think the nature of Clojure attracted more hobbyists than he thought. We’re trying to advertise better.

Depends on the use-case. If your use-case truly requires a map then you don’t try to cram it into a record. But yes, usually records > object > map. I’d say map and hashmap for us are interchangeable in terms of importance.

Tuple > Array > List. But again, different use-cases, as tuple is fix sized and heterogeneous. As for array vs list: just like how Clojure favors vector over seq. It’s even more so for us, since there’s a higher benefit in array for clean interop, performance, and mass usage (bad list usage looks pathologically bad).

Language tribalism is seldom about technical superiority. It’s more about:

  1. Partisanship. Feeling belonging in a niche group but expressing it violently. We don’t want those. Even within our own community we try to chill down some folks who are too gun-ho about ReScript. Excitement is fine ofc.
  2. Immature learning process. Tbh I used to be a little like that too. The first few languages I learned, I thought they were the coolest and every other language that didn’t realize this were worse off. Turned out to be just ignorance; language design is hard enough that you can bet that whoever shipped a language already knows whatever cool paradigm you/I’ve just learned.
  3. Sunken cost. Some people break up with their ex and curse them, some others instead wish their partner good luck. You wanna hang out with the latter in a language community and in life.

More toward the middle. A bit of procedural, a bit of functional, a bit of mutation, and a bit of side-effect. And yes, escape hatches. Philosophically, we’re more like Go/Clojure/ObjC than Scala/Haskell/Swift or even OCaml. Technically, we’re slightly more like the latters due to historical reasons.

You’d either need to represent Id as a variant + generic helper on those variant cases, or make one helper function per such normalize-with field you’re dealing with, plus a default helper, because in all likelihood you might end up having to specialize the normalization of certain fields. Use such helper with the generic processor. Does that make sense? And yeah later on you’d encode those objects into generic jsons. If you need bigger examples, feel free to create a dedicated new thread =).