How to express Js.Dict.t with union type?

I have a function in js library:

function handler(files) {
  // do something
}

The type of parameter files is that:

type file = {
  name: string,
  path: string
}
type files = {
  [index: string]: file | file[]
}

I don’t know how to import function handler in rescript.

TypeScript unions are not possible in ReScript types, but there are some workarounds.

You can cast each type into an abstract type that represents the union. Example:

type files_union
external cast_file: file => files_union = "%identity"
external cast_file_array: array<file> => files_union = "%identity"

The drawback to this method is that the casting is a one-way trip. Once a value has been cast, you can’t do anything with it in ReScript anymore.

Usually, it’s more practical to just use a different function for each type in the union. If you’re dealing with external bindings, it may look like this:

@module("foo") external bar: file => unit = "bar"
@module("foo") external bar_array: array<file> => unit = "bar"

Here, bar and bar_array are both bound to the same external function, but their types are different.

One last thing to consider: is there any difference between a single file value and a single-element array containing a file? (Like [file]). If there isn’t, then you can probably just use array<file> in your binding and use a single-element array if you only have one file.

Also, I’m assuming that you’re constructing this value to send to an external JavaScript function. If you’re receiving this value from an external source, then that’s different. You’ll need to use a function like Js.typeof and handle it at runtime.

1 Like

Its not a one way trip if you can introduce an external function to cast back, as you mention below. right?
external cast_files_union_file: files_union => option<file> = %raw(...)?

1 Like

Hi @Mng12345 just adding some example code using the ideas from @johnj’s comment.

Since the dictionary values need a fixed type, but you have multiple types, you might like to treat your input as Js.Dict.t<Js.Json.t> rather than Js.Dict.t<'a>.

You can then write a function that parses/classifies your dictionary values:

type file = {
  name: string,
  path: string,
}

type files = File(file) | Files(array<file>)

external jsonToFile: Js.Json.t => file = "%identity"
external jsonToFileArray: Js.Json.t => array<file> = "%identity"

let parseFiles: Js.Json.t => files = json => {
  if Js.Array2.isArray(json) {
    Files(jsonToFileArray(json))
  } else {
    File(jsonToFile(json))
  }
}

Here’s an example using it:

let getFiles = (dict, key): array<file> => {
  let value = Js.Dict.get(dict, key)
  switch value {
  | None => []
  | Some(json) =>
    switch parseFiles(json) {
    | File(file) => [file]
    | Files(files) => files
    }
  }
}

let dict: Js.Dict.t<Js.Json.t> = %raw(`
{
  "1": { name: "n1", path: "p1" },
  "2": [ { name: "n2", path: "p2" },  { name: "n3", path: "p3" } ]
}
`)

Js.log(getFiles(dict, "1"))
Js.log(getFiles(dict, "2"))

This code assumes the values you’ve received are file or file[].

4 Likes

Don’t know if it’s gonna be helpful, but usually when i need to bind to function which take a single element or an array of element, i only bind to the array of element.

2 Likes

Hi @Mng12345 it looks like you’ve edited your question.

@johnj has a nice solution above:

@module("foo") external bar: file => unit = "bar"
@module("foo") external bar_array: array<file> => unit = "bar"

For your context it might look something like this:

// MyLib.res

type file = {
  name: string,
  path: string,
}

@module("mylib") external handleFile: file => unit = "handler"
@module("mylib") external handleFiles: array<file> => unit = "handler"

You might also like to consider @carere’s idea - if you don’t have much use case for the single file version, then you only write a binding for the array version.

Hope that helps.

@kevanstannard
The parameter files is an object not file or array.
I can use this workround to fix this problem in that way.

@module("foo") external handleFiles: array<file> => unit = "handler"

let handleFile = (file: file) => {
  handleFiles([file])
}

But if parameter is object with type:

{
  [index: string]: file | file[]
}

There is no good way i can use to import the function if it’s parameter is an object and the value of the object is union type.

1 Like

Hi @Mng12345 thanks for clarifying.

Probably the simplest option here is to only use arrays, following the suggestion from @carere E.g.

// MyLib.res

type file = {
  name: string,
  path: string,
}

type files = Js.Dict.t<array<file>>

@module("mylib") external handleFiles: files => unit = "handler"

Example call:

let file1: MyLib.file = {name: "name1", path: "path1"}
let file2: MyLib.file = {name: "name2", path: "path2"}
let file3: MyLib.file = {name: "name3", path: "path3"}
let files = Js.Dict.empty()
files->Js.Dict.set("1", [file1])
files->Js.Dict.set("2", [file2, file3])

MyLib.handleFiles(files)

If you think it might be useful to use both single files and arrays of files, then you could do something like this:

// MyLib.res

type file = {
  name: string,
  path: string,
}

type fileOrFiles = File(file) | Files(array<file>)

// Loop through each value
// [1] Unwrap to just the file or array<file>
// [2] Encode as Js.Json.t using techniques described above
let encode: Js.Dict.t<fileOrFiles> => Js.Dict.t<Js.Json.t> = // TODO

// Helper external function
@module("mylib") external handleFiles_: Js.Dict.t<Js.Json.t> => unit = "handler"

let handleFiles: Js.Dict.t<fileOrFiles> => unit = files => handleFiles_(encode(files))

And use it something like this:

let files = Js.Dict.empty()
files->Js.Dict.set("1", MyLib.File({name: "name1", path: "path1"}))
files->Js.Dict.set(
  "2",
  MyLib.Files([{name: "name2", path: "path2"}, {name: "name3", path: "path3"}]),
)

MyLib.handleFiles(files)

There are some improvements to make this a little more type safe, but this is hopefully a simple start.

How does that look to you?

1 Like