rescript-lang / rescript

ReScript is a robustly typed language that compiles to efficient and human-readable JavaScript.
https://rescript-lang.org
Other
6.77k stars 453 forks source link

RFC: Protected record fields #7018

Open cometkim opened 2 months ago

cometkim commented 2 months ago

Motivation

Customizable variants greatly enhance the experience of using ReScript records in JavaScript.

But it still lacks encapsulation capabilities. When building a library in JS, implementers can put internal values ​​in secret fields that are not exposed to end users.

This is usually achieved through symbols.

The symbol is an effective way to protect the package's internal knowledge.

e.g. https://github.com/facebook/react/blob/a03254bc/packages/shared/ReactSymbols.js

and it can simulate other advanced features such as private fields.

const super_secret_key = Symbol()

export class MyClass {
  // This is good because the symbol has better compatibility than private field syntax
  [super_secret_key]() {
    return "I'm safe";
  }
}

This can be used as a key in any JS object. It is also suitable for use as a ReScript record key.

So I proposed #6738 to use symbols by default in all ReScript internal values, but it had issues and seems not viable.

However, it still seems useful for some cases to make this a choice for the user rather than the compiler.

Also useful for the compiler itself https://github.com/rescript-lang/rescript-compiler/blob/d9d5800f3/jscomp/runtime/caml_option.resi#L25

Design

@as(symbol) for customizable variants

Uses the same syntax as the customizable variants, but uses @as(symbol) as a reserved one.

type data = {
  @as(symbol) foo: string,
  bar: string,
}

let data = {
  foo: "foo",
  bar: "bar",
}

Emits

let data = {
  [Symbol.for("Module.data.foo")]: "foo",
  bar: "bar",
};

export {
  data,
};

The values ​​​​will automatically assigned with Symbol.for({typepath}).

Symbol.for can be used without module resolution because it is tracked by JS. So it has a fairly low implementation complexity and make it easier to debug.

Symbol as tag

@tag("tag")
type t =
  | @as(symbol) Foo
  | @as(symbol) Bar({ bar: string })

let foo = Foo
let bar = Bar({ bar: "bar" })
let foo = Symbol.for("Module.t$Foo")
let bar = {
  tag: Symbol.for("Module.t$Bar"),
  bar: string,
}

Or even

@tag(symbol)
type t =
  | @as(symbol) Foo
  | @as(symbol) Bar({ bar: string })

let foo = Foo
let bar = Bar({ bar: "bar" })
let foo = Symbol.for("Module.t$Foo")
let bar = {
  [Symbol.for("Module.t")]: Symbol.for("Module.t$Bar"),
  bar: string,
}

Symbol as value

Non-goal, as we already can use symbol as value just like normal FFI syntax.

type s
external mySymbol: unit => s = "Symbol"
let mySymbol = mySymbol()

TypeScript

genType will not generate types for fields designated as a symbol.

cometkim commented 2 months ago

The downside is as mentioned in https://github.com/rescript-lang/rescript-compiler/issues/6738#issuecomment-2075174646

Using this feature can reduce interoperability with other ser/de libraries.

zth commented 2 months ago

@cometkim could you point to a few real world examples? Not opposed the functionality, just not something I've encountered often myself.

cometkim commented 2 months ago

As mentioned in the text, we already have some internal fields to protect.

It will be useful when the ReScript user wants to hide such fields ​​or protect records from JS/TS side.

Many real-world libraries have objects with internal fields, which are marked with an underscore prefix https://github.com/search?q=_cache+language%3AJavaScript&type=code