rescript-compiler icon indicating copy to clipboard operation
rescript-compiler copied to clipboard

[NotForNow] GenType: explore the idea of using namespaces to represent modules.

Open cristianoc opened this issue 2 years ago • 13 comments

Explore a cleaner way to represent ReScript modules in TypeScript using namespaces

Background

In ReScript, modules are used to organize code and encapsulate related types and values. ReScript modules can contain values (such as functions) and type declarations, allowing users to access them using the syntax M.t for a type t defined inside module M. Modules can also be nested, and ReScript supports functors (functions between modules).

Currently, genType maps ReScript modules to TypeScript constructs, but there might be a cleaner way to represent them using TypeScript namespaces.

Proposed Solution

The proposed solution is to represent ReScript modules and their features in TypeScript using namespaces:

  1. Representing ReScript modules: Use TypeScript namespaces to group related types and values.

    namespace M {
      export type t = {
        // Your type definition goes here
      };
    
      export function someFunction(): t {
        // Your function implementation goes here
      }
    }
    
    
  2. Representing nested ReScript modules: Use nested TypeScript namespaces.

namespace M {
  export type t = {
    // Your type definition goes here
  };

  export function foo(): t {
    // Your function implementation goes here
  }

  export namespace N {
    export type t = {
      // Your type definition goes here
    };

    export function bar(x: M.t): t {
      // Your function implementation goes here
    }
  }
}
  1. Accessing a namespace defined in another file: Export the namespace from the source file and then import it in the file where you want to access it.
// Somefile.ts
export namespace SomeModule { /* ... */ }

// AnotherFile.ts
import { SomeModule } from './Somefile';
  1. Mimicking ReScript functors: Use higher-order functions that take objects representing namespaces as input and return objects representing namespaces.
// Define functor-like higher-order function
function myFunctor(input: typeof InputNamespace): typeof OutputNamespace { /* ... */ }

// Use the functor-like higher-order function
const outputModule = myFunctor(InputNamespace);

cristianoc avatar Apr 09 '23 03:04 cristianoc

Screenshot 2023-04-09 at 05 31 01 Screenshot 2023-04-09 at 05 31 30 Screenshot 2023-04-09 at 05 31 51

cristianoc avatar Apr 09 '23 03:04 cristianoc

It will not work with transpilers like esbuild or Babel. Even if use tsc, it hurts the possibility of tree-shaking of build artifacts.

cometkim avatar Apr 11 '23 17:04 cometkim

It will not work with transpilers like esbuild or Babel. Even if use tsc, it hurts the possibility of tree-shaking of build artifacts.

That's interesting. Can you expand on that?

cristianoc avatar Apr 11 '23 17:04 cristianoc

It will not work with transpilers like esbuild or Babel.

OK, it's outdated. @babel/plugin-transform-typescript and esbuild both support transpiling TS namespaces.

However, it is not "fully" supported, and it is not a recommended option. Babel recommends using modules where possible. https://babeljs.io/docs/babel-plugin-transform-typescript#impartial-namespace-support

it hurts the possibility of tree-shaking of build artifacts.

I already rely on the GenType to generate TypeScript modules in ES Module format. https://github.com/reason-seoul/rescript-collection/blob/main/examples/ts-rescript-vector/src/index.ts

When I use the library, the bundler can remove the parts I don't use. Module semantics make easy to do that.

But, the result of namespace:

// generated by tsc
"use strict";
var M;
(function (M) {
    function foo() {
        // Your function implementation goes here
    }
    M.foo = foo;
    let N;
    (function (N) {
        function bar(x) {
            // Your function implementation goes here
        }
        N.bar = bar;
    })(N = M.N || (M.N = {}));
})(M || (M = {}));

Usually, it comes as an IIFE that negates most DCE tools. Most library authors avoid using TS namespaces. The only time it's valid is when used in only type-level and not in runtime code.

cometkim avatar Apr 11 '23 17:04 cometkim

It will not work with transpilers like esbuild or Babel.

OK, it's outdated. @babel/plugin-transform-typescript and esbuild both support transpiling TS namespaces.

However, it is not "fully" supported, and it is not a recommended option. Babel recommends using modules where possible. https://babeljs.io/docs/babel-plugin-transform-typescript#impartial-namespace-support

it hurts the possibility of tree-shaking of build artifacts.

I already rely on the GenType to generate TypeScript modules in ES Module format. https://github.com/reason-seoul/rescript-collection/blob/main/examples/ts-rescript-vector/src/index.ts

When I use the library, the bundler can remove the parts I don't use. Module semantics make easy to do that.

But, the result of namespace:

// generated by tsc
"use strict";
var M;
(function (M) {
    function foo() {
        // Your function implementation goes here
    }
    M.foo = foo;
    let N;
    (function (N) {
        function bar(x) {
            // Your function implementation goes here
        }
        N.bar = bar;
    })(N = M.N || (M.N = {}));
})(M || (M = {}));

Usually, it comes as an IIFE that negates most DCE tools. Most library authors avoid using TS namespaces. The only time it's valid is when used in only type-level and not in runtime code.

Thanks, this simplifies the design a lot -- no need to explore namespaces further!

I'll ask you some more gentype-related questions too. What about the opportunity of generating .d.ts files? I assume it would simplify a lot of things. But it's just an assumption.

cristianoc avatar Apr 11 '23 17:04 cristianoc

You mean only create .d.ts files for .bs.js instead of TS wrappers? I think that would make sense if we make the JS representation more directly usable.

GenType is actually doing the mapping of the runtime representation as well as adding an alias to the typename. I still have to rely on it.

An example code I have.. source:

@genType
let hasNoAccounts = async (~countAllMembers) => {
  switch await countAllMembers(.) {
  | count => Ok(count == 0)
  | exception Js.Exn.Error(exn) => Error(#IOError({"exn": exn}))
  }
}

JS result:

async function hasNoAccounts(countAllMembers) {
  var count;
  try {
    count = await countAllMembers();
  }
  catch (raw_exn){
    var exn = Caml_js_exceptions.internalToOCamlException(raw_exn);
    if (exn.RE_EXN_ID === Js_exn.$$Error) {
      return {
              TAG: /* Error */1,
              _0: {
                NAME: "IOError",
                VAL: {
                  exn: exn._1
                }
              }
            };
    }
    throw exn;
  }
  return {
          TAG: /* Ok */0,
          _0: count === 0
        };
}

GenType result:

export const hasNoAccounts: (_1:{ readonly countAllMembers: (() => Promise<number>) }) => Promise<
    { tag: "Ok"; value: boolean }
  | { tag: "Error"; value: { readonly exn: Js_Exn_t } }> = function (Arg1: any) {
  const result = Council_Service_AccountBS.hasNoAccounts(Arg1.countAllMembers);
  return result.then(function _element($promise: any) { return $promise.TAG===0
    ? {tag:"Ok", value:$promise._0}
    : {tag:"Error", value:$promise._0}})
};

BTW I'm pretty sure the output can be simplified. It adds too much overhead today.

GenType result in the real-world
export const verifyMemberSession: <T1>(_1:{
  readonly findSession: ((_1:Council_Entity_Session_id) => Promise<(null | undefined | Council_Entity_Session_t)>); 
  readonly findMember: ((_1:T1) => Promise<(null | undefined | Council_Entity_Member_t)>); 
  readonly sessionId: (null | undefined | Council_Entity_Session_id); 
  readonly memberId: (null | undefined | T1)
}) => Promise<
    { tag: "Ok"; value: { readonly member: Council_Entity_Member_t; readonly session: Council_Entity_Session_t } }
  | { tag: "Error"; value: 
    { NAME: "IOError"; VAL: { readonly exn: Js_Exn_t } }
  | { NAME: "InvalidMember"; VAL: { readonly member?: T1 } }
  | { NAME: "InvalidSession"; VAL: { readonly session?: Council_Entity_Session_id } } }> = function <T1>(Arg1: any) {
  const result = Curry._4(Council_Service_SessionBS.verifyMemberSession, function (Arg11: any) {
      const result1 = Arg1.findSession(Arg11);
      return result1.then(function _element($promise: any) { return ($promise == null ? undefined : {_RE:$promise._RE, id:$promise.id, seq:$promise.seq, events:$promise.events.map(function _element(ArrayItem: any) { return ArrayItem.tag==="Created"
        ? Object.assign({TAG: 0}, ArrayItem.value)
        : Object.assign({TAG: 1}, ArrayItem.value)}), state:($promise.state == null ? undefined : $promise.state.tag==="Anonymous"
        ? Object.assign({TAG: 0}, $promise.state.value)
        : Object.assign({TAG: 1}, $promise.state.value))})})
    }, function (Arg12: any) {
      const result2 = Arg1.findMember(Arg12);
      return result2.then(function _element($promise1: any) { return ($promise1 == null ? undefined : {_RE:$promise1._RE, id:$promise1.id, seq:$promise1.seq, events:$promise1.events.map(function _element(ArrayItem1: any) { return ArrayItem1.tag==="Created"
        ? Object.assign({TAG: 0}, ArrayItem1.value)
        : ArrayItem1.tag==="SingupApproved"
        ? Object.assign({TAG: 1}, ArrayItem1.value)
        : ArrayItem1.tag==="SingupRejected"
        ? Object.assign({TAG: 2}, ArrayItem1.value)
        : ArrayItem1.tag==="AdminGranted"
        ? Object.assign({TAG: 3}, ArrayItem1.value)
        : ArrayItem1.tag==="AdminRevoked"
        ? Object.assign({TAG: 4}, ArrayItem1.value)
        : ArrayItem1.tag==="JoinedToOrganization"
        ? Object.assign({TAG: 5}, ArrayItem1.value)
        : ArrayItem1.tag==="LeaveFromOrganization"
        ? Object.assign({TAG: 6}, ArrayItem1.value)
        : ArrayItem1.tag==="Reactivated"
        ? Object.assign({TAG: 7}, ArrayItem1.value)
        : Object.assign({TAG: 8}, ArrayItem1.value)}), state:($promise1.state == null ? undefined : $promise1.state.tag==="Requested"
        ? Object.assign({TAG: 0}, $promise1.state.value)
        : $promise1.state.tag==="Rejected"
        ? Object.assign({TAG: 1}, $promise1.state.value)
        : $promise1.state.tag==="Active"
        ? Object.assign({TAG: 2}, $promise1.state.value)
        : Object.assign({TAG: 3}, $promise1.state.value))})})
    }, (Arg1.sessionId == null ? undefined : Arg1.sessionId), (Arg1.memberId == null ? undefined : Arg1.memberId));
  return result.then(function _element($promise2: any) { return $promise2.TAG===0
    ? {tag:"Ok", value:{member:{_RE:$promise2._0.member._RE, id:$promise2._0.member.id, seq:$promise2._0.member.seq, events:$promise2._0.member.events.map(function _element(ArrayItem2: any) { return ArrayItem2.TAG===0
    ? {tag:"Created", value:ArrayItem2}
    : ArrayItem2.TAG===1
    ? {tag:"SingupApproved", value:ArrayItem2}
    : ArrayItem2.TAG===2
    ? {tag:"SingupRejected", value:ArrayItem2}
    : ArrayItem2.TAG===3
    ? {tag:"AdminGranted", value:ArrayItem2}
    : ArrayItem2.TAG===4
    ? {tag:"AdminRevoked", value:ArrayItem2}
    : ArrayItem2.TAG===5
    ? {tag:"JoinedToOrganization", value:ArrayItem2}
    : ArrayItem2.TAG===6
    ? {tag:"LeaveFromOrganization", value:ArrayItem2}
    : ArrayItem2.TAG===7
    ? {tag:"Reactivated", value:ArrayItem2}
    : {tag:"Deactivated", value:ArrayItem2}}), state:($promise2._0.member.state == null ? $promise2._0.member.state : $promise2._0.member.state.TAG===0
    ? {tag:"Requested", value:$promise2._0.member.state}
    : $promise2._0.member.state.TAG===1
    ? {tag:"Rejected", value:$promise2._0.member.state}
    : $promise2._0.member.state.TAG===2
    ? {tag:"Active", value:$promise2._0.member.state}
    : {tag:"Inactive", value:$promise2._0.member.state})}, session:{_RE:$promise2._0.session._RE, id:$promise2._0.session.id, seq:$promise2._0.session.seq, events:$promise2._0.session.events.map(function _element(ArrayItem3: any) { return ArrayItem3.TAG===0
    ? {tag:"Created", value:ArrayItem3}
    : {tag:"MemberConnected", value:ArrayItem3}}), state:($promise2._0.session.state == null ? $promise2._0.session.state : $promise2._0.session.state.TAG===0
    ? {tag:"Anonymous", value:$promise2._0.session.state}
    : {tag:"Member", value:$promise2._0.session.state})}}}
    : {tag:"Error", value:$promise2._0}})
};

cometkim avatar Apr 11 '23 18:04 cometkim

GenType is actually doing the mapping of the runtime representation as well as adding an alias to the typename. I still have to rely on it.

Not anymore. In v11, the runtime representation is tunable in the language, and genType does no conversion.

cristianoc avatar Apr 11 '23 18:04 cristianoc

Polymorphic variants can be specified with quotes.

cristianoc avatar Apr 11 '23 18:04 cristianoc

I can't remember anything having changed in polymorphic variants in v11. In general, customising polymorphic variants is more difficult because type inference would lose all customisations. But language-level things such as #42 and #\"AAAA" are preserved.

cristianoc avatar Apr 11 '23 18:04 cristianoc

I'll move this to v12, and probably beyond, in case it makes sense to revisit when tooling around bundling changes.

cristianoc avatar Apr 12 '23 02:04 cristianoc

What about the opportunity of generating .d.ts files? I assume it would simplify a lot of things. But it's just an assumption.

According to this comment, I'm drawing a version of the gentype that only handles types (.d.ts files). It'll bring many advantages and is enough for my own use cases.

I wonder why did gentype include the runtime in the first place?

cometkim avatar Apr 20 '23 18:04 cometkim

It included runtime as the runtime representation required conversion. E.g. variants would map to incomprehensible numbers on the JS side.

There's still a bit of runtime for @genType.import but that can be looked at separately.

cristianoc avatar Apr 20 '23 19:04 cristianoc

ES proposal "module declarations" (currently stage 2) could be more proper target

https://github.com/tc39/proposal-module-declarations

cometkim avatar Dec 14 '23 12:12 cometkim