A question about optional record fields

Hello :wave:

I have a question about the following code.

type t = {
  foo: string,
  bar?: string,
}

let make = (~foo, ~bar=?, ()) => { foo, ?bar }

let a = { foo: "dummy" }
let b = { foo: "dummy", bar: ?None }

compiles to:

function make(foo, bar, param) {
  return {
          foo: foo,
          bar: bar
        };
}

var a = {
  foo: "dummy"
};

var b = {
  foo: "dummy"
};

export {
  make ,
  a ,
  b ,
}

Why doesn’t ?None add a field "bar": undefined ?

function make(foo, bar, param) {
  return {
          foo: foo,
          bar: bar
        };
}

var a = {
  foo: "dummy"
};

var b = {
  foo: "dummy"
  bar: undefined,
};

export {
  make ,
  a ,
  b ,
}

I have a problem with this because I want to write a test on a function that returns a record with optional fields. expect(make(~foo="", ()))->toStrictEqual({ foo: "" }). But the deep equality doesn’t work : the function returns a property value undefined in the record.

The only way around this problem is to write:

type t = {
  foo: string,
  bar: option<string>,
}

let make = (~foo, ~bar=?, ()) => { foo, bar }

let a = { foo: "dummy", bar: None }

Have you ever experienced this problem?

Thank you for your help

Léo

Maybe an aside but can I challenge the nature of your test?
Make expectations of the fields you have provided?

expect make to have field foo with value v.
expect make to only have one field (questionable benefit?)

Can you show specifically how you are getting that? I am trying the function but not getting it:

type t = {
  foo: string,
  bar?: string,
}

let make = (~foo, ~bar=?, ()) => { foo, ?bar }

let a = { foo: "dummy" }
let b = { foo: "dummy", bar: ?None }
let c = make(~foo="", ())

Output for c is:

var c = {
  foo: ""
};

I know I’m not exactly stating a solution to your problem, but your described behaviour is to be expected:

Optional record fields were mainly intended (AFAIK) for better interop with config objects which is a common pattern in js. Many APIs using this pattern choke on the data if a field is present but undefined.

Historically, similar things could be realized using @obj.

The important differences:

  • this defines a field to be optional: fieldName?: string:
    • the field may or may not be present at all
    • rescript will use a type of option<string> to model if the field is present
    • when defining a value of this record type, an optional field may be omitted
  • define a field to be of type option in a record having optional fields: fieldName: option<string>
    • when defining a value of this record type, this field may not be omitted
    • this field of value None won’t be present in the js output
  • @obj external (~a: int=?, ~b: string, unit) => t = ""
    • fields defined by an optional labeled argument my not be present in the js representation (if omitted)
    • non optional fields of type option will be present in the js representation, independent of it’s value

These are subtle but important differences.

Here are some examples to demonstrate

Res

@obj
external o: (~a: int=?, ~b: string, ~c: option<bool>, unit) => _ = ""

let a = o(~b="hi", ~c=None, ())
let b = o(~a=42, ~b="ciao", ~c=Some(true), ())

////////////////

type r = {
  a?: int,
  b: string,
  c: option<bool>,
}

let x = {b: "hi", c: None}
let y = {a: 42, b: "ciao", c: Some(true)}

////////////////

type recordWithoutOptionalFields = {
  x: string,
  y: option<bool>,
}

let v = {x: "x", y: None}
let w = {x: "xx", y: Some(true)}

JS

// Generated by ReScript, PLEASE EDIT WITH CARE


var a = {
  b: "hi",
  c: undefined
};

var b = {
  a: 42,
  b: "ciao",
  c: true
};

var x = {
  b: "hi"
};

var y = {
  a: 42,
  b: "ciao",
  c: true
};

var v = {
  x: "x",
  y: undefined
};

var w = {
  x: "xx",
  y: true
};

export {
  a ,
  b ,
  x ,
  y ,
  v ,
  w ,
}
/* No side effect */

Edit: fixed codeblocks to enable highlighting.

3 Likes

Thank you for your feedbacks.

To give more context. I have a graphql query results that I want to transform into another format with optional fields record.

module Query = %graphql(`…`)

type row = {foo: string, bar?: string}

let rowsFromQueryResults = queryResult => queryResult->Array.map(item => { foo: item.foo, bar: ?item.bar })

let make = () => 
  …
  let queryResults = Query.use()
  let … = rowsFromQueryResults(queryResults)
  …

I just want to write a test for my rowsFromQueryResults function.

expect(rowsFromQueryResults([…]))->toStrictEqual([{ foo: "…" }, { foo: "…", bar: "…" }])

But it doesn’t work. As @woeps explained very well, with a record without optional fields, there is no problem (the optional fields are not omitted)

But I wonder if it would make more sense for the rescript compiler to compile ?None to undefined instead of omitting the property value in the js output.

Because :

type r = {
  a?: int,
  b: string,
}

let x = {b: "hi"}
let y = {a: ?None, b: "hi"}

x and y have the same output. I find this strange. This prevents me from using record having optional fields because I can’t test them.

@yawaramin Your case is different from mine. You have static variables, so Rescript can optimize the js output.