Guidance for nested data types

Hi. It is just a post for discussion. I know there is no silver bullet for all use cases, but when coming from TypeScript to ReScript type system, you need to change your view drastically.

For example, I am modelling simplified terminal window, and want to expose methods to manipulate output in two cases - add/remove lines and scroll the viewport (when there are more lines than can be shown). What is the recommended way to deal with API:
a) Provide a single type of all use cases and single method - run
b) Provide two types for each part and two methods - screen, viewport

In an essence, I think this is a question about: 1) should I nest my data types or not, 2) should I transfer responsibility from method to data type, 3) how distinct or similar method and data type when dealing with business logic modelling.

Maybe there are some books, articles and examples that you can recommended to get a better grasp on this!

Code for single method:

type direction = Up | Down | Reset
type command =
  | Clear
  | Echo(string)
  | Focus(direction)

type t = {
  lines: array<string>,
  run: command => unit,
}

let useDisplay = () => {
  let lines = []

  let run = command => {
    switch command {
    | Clear => ()
    | Echo(line) => ()
    | Focus(direction) => ()
    }
  }

  {lines, run}
}

Code for two methods:

type direction = Up | Down | Reset
type command =
  | Clear
  | Echo(string)

type t = {
  lines: array<string>,
  screen: command => unit,
  viewport: direction => unit,
}

let useDisplay = () => {
  let lines = []

  let viewport = direction => {
    switch direction {
    | Reset => ()
    | Down => ()
    | Up => ()
    }
  }

  let screen = command => {
    switch command {
    | Clear => ()
    | Echo(line) => ()
    }
  }

  {lines, screen, viewport}
}

Well it depends a lot on what you want to achieve. The first design makes it easier to extend or to centralize error handling, but it might have less clear separation of concerns. The second makes it a bit harder to use but is a bit more scalable in the long run.

Personally I would just nest here, and only decide to move to the second approach when I find there is too much friction.

We have some threads on data modeling here, like this one: Domain modeling in Rescript - Resources, Patterns? which also contains book recommendations.

2 Likes

The core principles of functional programming are immutability and separation of code and data.

The most common and idiomatic way to apply this in rescript is with the following module architecture: a main type t that ‘tracks’ the state and that can be abstract in its interface file and functions to interact with this type that return a new state instead of mutating their input:

module Terminal: {
  type t
  type direction = Up | Down | Reset
  let make: (~height: int=?) => t
  let clear: t => t
  let echo: t => t
  let move: (t, ~direction: direction) => t
  let updateCurrentCommand: (t, string) => t
  let getCurrentCommand: t => string
  let getDisplayedLines: t => array<string>
} = {
  type t = {
    content: array<string>,
    currentCommand: string,
    position?: int,
    height: int,
  }

  type direction = Up | Down | Reset

  let make = (~height=10) => {
    content: [],
    currentCommand: "",
    height,
  }

  let clear = t => {...t, content: [], position: ?None}

  let echo = t => {
    ...t,
    content: [...t.content, t.currentCommand],
    currentCommand: "",
    position: ?None,
  }

  let move = (t, ~direction) => {
    ...t,
    position: ?{
      let length = Array.length(t.content)
      let position = t.position->Option.getOr(length)
      switch direction {
      | Up => Int.clamp(~min=t.height, position - 1)->Some
      | Down =>
        let position = position + 1
        position > length ? None : Some(position)
      | Reset => None
      }
    },
  }

  let updateCurrentCommand = (t, currentCommand) => {...t, currentCommand}

  let getDisplayedLines = ({content, height, ?position}) => {
    let length = Array.length(content)
    let position = position->Option.getOr(length)
    content->Array.slice(
      ~start=Int.clamp(~min=0, position - height),
      ~end=Int.clamp(~min=position, height),
    )
  }
  let getCurrentCommand = ({currentCommand}) => currentCommand
}

Playground link (do not forget to enable Auto-run to try it live)

There should be more posts and books around how to architect your code idiomatically in rescript, meanwhile you can read books that focus on functional programming in general and especially the ones that use “pragmatic” functional programming languages like OCaml/F#.

4 Likes

I think it depends on how the rest of your app is going to be structured. I’m a sucker for doing #1 but it really works best if you’re handling it some single entry point and calling other methods to “evaluate” what should happen -

let whatShouldIDo = foo => {
  // ...
  Terminal.Echo("Hello World")
}

let main () => {
  let input = parse()
  let action = whatShouldIDo(input)
  Terminal.run(action)
}

If you expect the terminal to be referenced throughout code like in a UI with multiple buttons, then I’d go with what @tsnobip shown and create a Terminal module that simply defines all the methods

1 Like