Need help with binding to a function with a complex type

Heyall, I’ve recently decided to take a look at rescript and I really like it, and I’ve been using it to rewrite a small thing I have in javascript, and while the rewrite was really successful, I need some help on how to figure out how to write bindings to a particularly complicated function.

So there’s a global function in a utils class which is globally exposed to the js sandbox which the main code runs in, and the class has a function which works kinda like this:

utils.parseParametersFromArguments([{name: "index", type: "number"}, {name: "foo", type: "string"}], ["index:1", "foo:bar"])

In that particular example it returns

{ success: true, parameters: { index: 1, foo: "bar" }, args: [] }

And in case of a failure it would instead return something like this

{ success: false, reply: "Could not parse parameter \"index\"!" }

As you can see, you input a definition and then the arguments, and then it parses into that definition, or else it returns an error. In typescript that type is defined as

type ParameterValueMap = {
	string: string;
	number: number;
	boolean: boolean;
};
type ParameterType = keyof ParameterValueMap;

type ParameterDefinition = {
	readonly name: string;
	readonly type: ParameterType;
};
type ParameterDefinitions = readonly ParameterDefinition[];

static parseParametersFromArguments (
		paramsDefinition: ParameterDefinitions,
		argsArray: string[]
	): { success: true; parameters: Record<string, ParameterValue>; args: string[]; } | ResultFailure {

I was trying to do it kind of like this but i don’t think it’s particularly correct

  type parameterType = @string [ #string(string) | #number(float) | #boolean(bool) ]

  type parameterDefinition<'a> = {
    name: string,
    @as("type") type_: 'a,
  }

  type resultFailure = { success: bool, reply: string }
  type resultSuccess = { success: bool, parameters: Js.Dict.t<parameterType> }

  type resultRecord = {}

  @scope("utils") @val
  external parseParametersFromArguments: (
    array<parameterDefinition<parameterType>>, 
    array<string>
  ) => resultSuccess  = "parseParametersFromArguments"
1 Like

Welcome to the wonderful world of ReScript!

That example is quite tricky, but there are ways to handle it.

First, I would simplify the parameterType to this:

type parameterType =
  | @as("string") String
  | @as("number") Number
  | @as("boolean") Boolean

because according to your usage, you just pass
{name: "index", type: "number"} and not {name: "index", type: number(123)}, so no need to give the variants arguments IMO.

Second, if the ReScript type definition can be equivalent to the TS one, you don’t need a type parameter 'a for the parameterDefinition :

type parameterDefinition = {
  name: string,
  @as("type") type_: parameterType
}

Third, and admittedly this needs to be documented better, by combining unboxed variants and the tag decorator, you can create different variants from the same object, and the tag does not even have to be a string:

@tag("success")
type parseResult =
  | @as(true) Success({parameters: Js.Dict.t<parameterType>})
  | @as(false) Failure({reply: string})

This is a combination of your resultFailure and resultSuccess types. We set the tag to the success field. If it is true, then ReScript’s type system will treat it as a Success variant, if it’s false, then it will be a Failure variant.

Here is a full example on the ReScript playground.

5 Likes

hey, thanks you for letting me know about the tag decorator, that’s really nice to know.

i’m kinda confused about the parameterType, because with the way you’re showcasing it in the code, it doesn’t seem like there’s a way to get out the actual parameters after running the function, so for example, if i do this

let par = Utils.parseParametersFromArguments([{name: "index", type_: Number}], args)

this returns a parseResult and i can match on it but if i match on it, in the success i only get a dict of parameter types with seemingly no payload.

i really appreciate you for helping, and it would be really awesome if you could show me how i could modify this to make it possible to then do something like

    switch par {
    | Success({parameters}) => {
        let index = parameters->Js.Dict.get("index")

        switch index {
        | Some(i) => switch i {
          | Number(asd) => %todo
          | _ => %todo
          }
        | None => %todo
        }
      }
    | Failure(_) => {}
    }

(btw, if you put the type as Number into the function it’s guaranteed that it’s either gonna return a javascript number or fail to parse, resulting in a Failure)

((by returning a javascript number i mean that running the js code

utils.parseParametersFromArguments([{name: "index", type: "number"}, {name: "foo", type: "string"}], ["index:123"])

results in this object

{ "success": true, "parameters": { "index": 123 }, "args": [] }

and doing this

utils.parseParametersFromArguments([{name: "index", type: "number"}, {name: "foo", type: "string"}], ["index:asd"])
{ "success": false, "reply": "Could not parse parameter \"index\"!" }

Right, I missed the fact that you need only the keys for the input, but in the result you need the values.

So it’s

type parameterTypeKeys =
  | @as("string") String
  | @as("number") Number
  | @as("boolean") Boolean

@unboxed
type parameterType =
  | String(string)
  | Number(float)
  | Boolean(bool)

Modeled the keys as a separate type now as there is no keyOf in ReScript.
And the parameterType is indeed as you suggested in the last example. We can ensure the wrapper is optimized away in the resulting JS code with @unboxed.

Updated example: ReScript Playground

2 Likes

thank you so much, that seems to work magnificently.

it would be really awesome if it was possible for me to not have to switch on the value in the dict because i know it’s guaranteed to be whatever type i give to it but i guess that’s probably not possible, thank you so much for helping me.

I think it really depends on how you use this function, if you actually intend to process the result of this function, you’re then likely interested in the shape of this result. If you’re ok with a very light runtime overhead and some flexibility in the type safety, you could do something like that (heavily inspired by rescript-schema):

type rec parameterTypeKeys<_> =
  | @as("string") String: parameterTypeKeys<string>
  | @as("number") Number: parameterTypeKeys<float>
  | @as("boolean") Boolean: parameterTypeKeys<bool>

@tag("success")
type parseResult<'params> =
  | @as(true) Success({parameters: 'params})
  | @as(false) Failure({reply: string})

module S: {
  type t<'a>
  type helper = {field: 'a. parameterTypeKeys<'a> => 'a}
  let object: (helper => ({..} as 'a)) => t<'a>
} = {
  type parameterDefinition = {
    name: string,
    @as("type") type_: string,
  }
  type t<'a> = array<parameterDefinition>
  type helper = {field: 'a. parameterTypeKeys<'a> => 'a}
  external field: parameterTypeKeys<'a> => 'a = "%identity"

  external schemaToDictString: {..} => dict<string> = "%identity"

  let object = f => {
    f({field: field})
    ->schemaToDictString
    ->Dict.toArray
    ->Array.map(((name, type_)) => {name, type_})
  }
}

@scope("utils") @val
external parseParametersFromArguments: (
  S.t<{..} as 'a>,
  array<string>,
) => parseResult<'a> = "parseParametersFromArguments"

You’d then have type safety on the result:

let res = parseParametersFromArguments(
  S.object(s =>
    {
      "index": s.field(Number),
      "foo": s.field(String),
    }
  ),
  ["index:1", "foo:bar"],
)

switch res {
| Success({parameters}) if parameters["index"] == 1.0 =>
  Console.log("great success!")
| _ => Console.log("oh nooo!")
}

Playground link

Depending on what you’re looking for, this could be another solution.

3 Likes

oh wow, that looks really sweet to use, and yeah, i’m actually not too scared about the small runtime cost.

i really really appreciate the time y’all spent helping me with this.

i think i’d need some time to properly understand what all of this is doing as it’s definitely more complicated type stuff than i’ve personally had to do myself, but judging by the results i think it’s definitely really worth it.

thank you so so much <3333

2 Likes

I overcomplicated things a bit by using a generalized abstract data type (or GADT) for parameterTypeKeys and objects for the parsing result, objects being the structural equivalent of records in ReScript. Don’t worry, this kind of type shenanigans is quite uncommon in ReScript and is just needed here because of the very dynamic nature of the javascript function you’re binding to.

Here is a simplified version that doesn’t require GADTs and that allows both objects and records for the result:

@tag("success")
type parseResult<'params> =
  | @as(true) Success({parameters: 'params})
  | @as(false) Failure({reply: string})

module S: {
  type t<'a>
  type helper = {string: string, float: float, int: int, boolean: bool}
  let object: (helper => 'a) => t<'a>
} = {
  type parameterDefinition = {
    name: string,
    @as("type") type_: string,
  }
  type t<'a> = array<parameterDefinition>
  type helper = {string: string, float: float, int: int, boolean: bool}
  let string = "string"
  external unsafeStringToFloat: string => float = "%identity"
  let float = "number"->unsafeStringToFloat
  external unsafeStringToInt: string => int = "%identity"
  let int = "number"->unsafeStringToInt
  external unsafeStringToBool: string => bool = "%identity"
  let boolean = "boolean"->unsafeStringToBool

  external schemaToDictString: 'a => dict<string> = "%identity"

  let object = f => {
    f({string, float, int, boolean})
    ->schemaToDictString
    ->Dict.toArray
    ->Array.map(((name, type_)) => {name, type_})
  }
}

@scope("utils") @val
external parseParametersFromArguments: (
  S.t<'a>,
  array<string>,
) => parseResult<'a> = "parseParametersFromArguments"

You use it like that:

type myRecord = {
  index: int,
  foo: string,
}

let parseMyRecord = args =>
  parseParametersFromArguments(
    S.object(s => {
      index: s.int,
      foo: s.string,
    }),
    args,
  )

switch parseMyRecord(["index:1", "foo:bar"]) {
| Success({parameters: {index: 1}}) => Console.log("great success!")
| _ => Console.log("oh nooo!")
}

Playground link

1 Like