Rescript: How to model package.json / collection of two strings

Forgive the primitiveness of my question. I’m exploring Rescript and I’d like to open my package.json file into a typed datastore.

I’ve made some progress, but I need some direction:

@module("fs")
external readFileSync: (
  ~name: string,
   [#utf8],
) => string = "readFileSync"

type script = {
  name: string,
  value: string
}

type packageData = {
  name: string,
  version: string,
  scripts: array<script>,
  keywords: array<string>,
  author: option<string>,  
  license: string,
  dependencies: array<(string, string)>,
}

let file = readFileSync(~name="package.json", #utf8)
Console.log("File: " ++ file);

@scope("JSON") @val
external parseIntoMyData: string => packageData = "parse"

let result = parseIntoMyData(file)

Console.log("deps: " ++ result.scripts[0].name)

But the last line: 30 │ Console.log("deps: " ++ result.scripts[0].name) gives an error:

This has type: option<script>
But it's expected to have type: packageData

Here is the package.json content:

{
  "name": "cli",
  "version": "0.0.0",
  "scripts": {
    "res:build": "rescript",
    "res:clean": "rescript clean",
    "res:dev": "rescript -w"
  },
  "keywords": [
    "rescript"
  ],
  "author": "",
  "license": "MIT",
  "dependencies": {
    "@rescript/core": "1.4.0",
    "rescript": "11.1.1"
  }
}

I also posted this at SO but realized this forum might be the better choice.

In ReScript, Array access returns an option by default instead of giving you a runtime error when the Array is empty.

let deps = switch result.scripts[0] {
  | Some(packageData) => packageData.name
  | None => "(empty)"
}

Console.log("deps: " ++ deps)

It can be solved a bit more concisely with a helper function from the built-in Option module.

Console.log(
  "deps: " ++
  result.scripts[0]->Option.mapOr("(empty)", packageData => packageData.name),
)

Or, if you want the plain JS behavior for some reason, you can use Array.getUnsafe. Only use that when you know for a fact the array is populated.

Console.log("deps: " ++ (result.scripts->Array.getUnsafe(0)).name)
2 Likes

There are a few things going on here.

The first thing is that you defined multiple records that share the same fields (in this case, the field name), so the compiler thinks you’re referring to the latest record you’ve defined (packageData) when you actually want to refer to script. The best solution here (and the most idiomatic) is to namespace those types by placing them inside modules:

module Script = {
  type t = {
    name: string,
    value: string,
  }
}

module PackageData = {
  type t = {
    name: string,
    version: string,
    scripts: dict<string>,
    keywords: array<string>,
    author: option<string>,
    license: string,
    dependencies: array<(string, string)>,
  }
}

Then you’re facing another issue, you’re trying to get an element from an array, you might be used to think that it would return an element but rescript is a pretty safe language and the compiler takes into account the fact that there might be no element at this index so it returns an option of element, in this case, which forces you to handle both cases. For example here you could do:

Console.log(switch result.scripts[0] {
  | None => "no script defined"
  | Some({name}) => `first script: ${name}`
 })

But then you’d realize that scripts in package.json is not an array of script but actually a string dict, so you should model it accordingly.

See working example in the playground

But to be honest, if you’re planning to parse things, I’d actually advise you to use a serializer/deserializer library like rescript-schema or ppx-spice which will allow you to gracefully handle the cases where the data doesn’t follow the expected shape.

I hope it answers your question and that you’ll enjoy learning rescript! :slight_smile:

3 Likes