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?

15 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
3 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.

2 Likes

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

1 Like

This rfc will make rescript more easily to use!

2 Likes

This would be great!

A PoC of this that soon will be usable (hopefully) exists here: PoC of nested record definitions by zth · Pull Request #7241 · rescript-lang/rescript · GitHub

2 Likes

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 attribute to set the name for a generated type:@as

I think it’s right that reuse is explicit. What case does the @as addition proposal needed specifically?

It reminds me of the exportFragmentSpreadSubTypes option in the GraphQL code generator. It was quickly abused at scale, so it had to be disabled.

Yeah I lean towards that as well - make it a concrete definition if you need reuse. We can make the tooling make it super easy to extract to a concrete definition.

1 Like

Would nested variants be a stretch or do you want to keep this scoped to records?

Definitely not ruling out that, but we should start with records and see how that’s used. Variants are slightly different because of things like @unboxed and @tag where you often want/need to configure the variant at the type definition level. At a glance I think it’s a bit unclear how to achieve that in an intuitive way inline without introducing new syntax. But definitely not out of the question.

1 Like

I’m a little bit skeptic about using the @as attribute for renaming the underline types.

Because currently it exists to adjust the runtime representation of ReScript data and simply putting the @as to a wrong place will result in a completely different result:

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

I’d like to keep @as only for changing the runtime representation.

4 Likes

Would it be possible to have indexed access types to alias nested types directly? They are well proved on typescript nested record access

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

// alias for \"options.persist\"
type persistAlias = options["persist"]

// alias for \"options.persist.fileConfig\"
type fileConfigAlias = options["persist"]["fileConfig"]

1 Like

This feature and local type are super useful in Typescript.

1 Like

Unfortunately I don’t think that functionality from TypeScript translates well to ReScript. The simple cases might (just the equivalent of dot access), but add on things like variants, tuples and more and we’d need likely need to invent more syntax, and do a pretty complicated lookup of types. This lookup would also be alot less useful than in TS. As an example, what if you have a variant where 2 cases has the same prop name, but the props have different types? In TS it’d become typeOfA | typeOfB because that’s how the TS type system works. But in ReScript you can’t mix types like that.

Then there’s the added question around whether to keep things as explicit as possible, which would favor not having this type of lookup. This is also the reason why we’ve rejected @as.

Personally, that functionality in TS has almost always caused more issues than it has solved in codebases I’ve worked in.

Could be explored for sure if someone is interested in taking a stab at investigating how a complete ReScript version of that feature could look/work. But, just like when considering @as, I think we need to keep this type of thing simple to start out.

3 Likes

I’ve been sidetracked with other things, but I look forward to continuing work on this feature soon! Hoping we can land it in v12, that’d be great. Now that I know we can do it I find myself missing this feature almost daily :smile:

4 Likes

At this point, what benefit is coming out other than just defining the types?

type fileConfig = {
      extension?: string
    }

type persist = {
    fileName: string,
    path?: string,
    fileConfig?: 
  }

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

One of the things that draws me to using rescript at work is how straight forward the type system is even if it requires a little extra typing sometimes. I’ve seen some TS codebases that are a nightmare to understand due to getting creative with types

3 Likes

I think having the option is nice. imo this is easier to read through, as long as the inner types aren’t reused anywhere else.

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

and it seems as though the team is not encouraging reusing types that are defined this way. It should be encouraged that if a type is reused, break it out

1 Like

The proposal makes perfect sense when I understand records as immutable memory layouts that can no longer be split.

TypeScript’s structs have a quite different effect, since they allow declarations to be reinterpreted based on their structure. That seems pretty detrimental in a nominal language like ReScript.

1 Like

I leave some additional concerns:

  1. Polymorphic operations: I’ve argued for deprecating polymorphic operations we have since the introduction of universal operators in v12. For example, we support comparison operations between records. If we were to replace that with codegen based monomorphic operations, nested structures would add some complexity there.

  2. There’s actually a Record&Tuple proposal in progress in JS. Maybe we could experiment with it as an optional target once it’s mature enough. So we need to check in advance that the proposal can interoperate with JS record semantics. I think it’s probably a perfect match, but I’m not 100% sure yet.

1 Like