RFC: Nested record definitions

Today, if you want to have nested records, you need to define each record as a distinct type, in the correct order, and plug them together. An example:

type persistOptionsFileConfig = {
  extension?: string
}
type persistOptions = {
  fileName: string,
  path?: string,
  fileConfig?: persistOptionsFileConfig
}

type options = {
  startFrom: float,
  persist?: persistOptions
}

@module("some-package")
external someFn: (string, ~options: options=?) => unit = "someFn"

This is because each record is a distinct, nominal type, that needs a proper definition. We have objects ({ "someField": bool }) which are structural, and where you can nest the definition directly. But, most often what you want is a record, because of the other characteristics of records.

This has some pretty large cons:

  • Having to write out each record definition becomes tedious and annoying
  • Types in these scenarios are often “one shot” as in the types aren’t intended to be reusable anyway
  • It’s harder to read (you usually see type names instead of the actual structure), and the tooling doesn’t always print all of the relevant record types in hovers etc for you to quickly be able to see and understand the structure
  • Copy/pasting types directly from TS is harder because in TS you usually nest definitions (because the structural type system lets you do it) whereas in ReScript you need to break the structure down into its parts.This also makes LLMs worse at converting TS and ReScript

Suggestion: Allow nested record definitions

What if you could do this instead of the above:

type options = {
  startFrom: float,
  persist?: {
    fileName: string,
    path?: string,
    fileConfig?: {
      extension?: string
    }
  }
}

@module("some-package")
external someFn: (string, ~options: options=?) => unit = "someFn"

Just defining your records inline. They still need to be actual, defined types, so the above could essentially be sugar for this:

type \"options.persist.fileConfig\" = {
  extension?: string
}
type \"options.persist\" = {
  fileName: string,
  path?: string,
  fileConfig?: \"options.persist.fileConfig\"
}
type options = {
  startFrom: float,
  persist?: \"options.persist\"
}

@module("some-package")
external someFn: (string, ~options: options=?) => unit = "someFn"

Real types, with autogenerated names that aren’t possible to reference unless using escaped identifiers (a reminder that you should probably break it out to a real type definition if you want to reuse it).

One could also imagining supporting the @as attribute to set the name for a generated type:

type options = {
  startFrom: float,
  persist?: @as("persistOptions") { // makes the type `type persistOptions` instead of `type \"options.persist\"
    fileName: string,
    path?: string,
    fileConfig?: {
      extension?: string
    }
  }
}

Tooling

The editor tooling could:

  • Give an easy way of “persisting”/extracting a generated type to its own type definition that you can
  • Print the nested records directly in hovers etc, so you always get to see the full structure whenever you have a nested record

Thoughts and feedback

So, what are your thoughts and feedback on this? Do you think it’d be valuable?

2 Likes

+1 I have already wanted this more than once.

In your options example, would you be able to create or reference any of the nested types by themselves, or would they always need to be a part of option?

type options = {
  persist?: {
    fileName: string
  }
}

let persist = {
  fileName: "foo"
}

let persist = opt => opt.fileName
2 Likes

They’d work by themselves, they’ll be an actual defined type to the compiler. It’s just that (in this proposal as of now) referring to them by typename can’t be done without using escaped identifiers, or by using the @as annotation. Your example above would work just fine because it’d be inferred to be the persist type.

1 Like

I’ve had the need for this quite often when writing bindings!

1 Like