In a handwritten d.ts file, how can I expose functions from one namespace in the module root?

1k views Asked by At

I'm working on a repo that's all in javascript but that exports handwritten type declarations (automerge/index.d.ts).

The structure of the codebase is that it has a Frontend and a Backend, plus a public API that offers some convenience functions of its own, in addition to re-exporting some functions directly from the Frontend and the Backend.

Something like this:

declare module `foo` {

  // functions that only exist in the public API
  function a
  function b
  function c

  // functions exposed directly from namespace A
  function q
  function r
  function s

  // functions exposed directly from namespace B
  function x
  function y
  function z

  namespace A {
    function q
    function r
    function s
    function t
  }

  namespace B {
    function v
    function w
    function x
    function y
    function z
  }

}

Here's an excerpt from the actual code showing how we're currently writing duplicate declarations for the re-exported functions.

declare module 'automerge' {
  ...

  function getObjectById<T>(doc: Doc<T>, objectId: OpId): Doc<T>
  
  namespace Frontend {
    ...

    function getObjectById<T>(doc: Doc<T>, objectId: OpId): Doc<T>
  }

  ...
}

Is there a way to avoid writing these declarations twice?

4

There are 4 answers

0
mihi On

One possibility would be to define an arrow function type alias and use that in both places. E.g.:

declare module "automerge" {
    type GetObjectById = <T>(doc: Doc<T>, objectId: OpId) => Doc<T>
    
    const getObjectById: GetObjectById

    namespace Frontend {
        const getObjectById: GetObjectById
    }
}

Unfortunately it is not possible to do the same directly with "regular" function declarations (see here).

Arrow functions and function declarations are not exactly the same, especially around scoping of this within the function. For example arrow functions cannot have a this parameter:

// not allowed
const fn = (this: SomeContext) => void
// allowed
function fn(this: SomeConext): void

But if you are not relying on any features they differ on, or can just switch to arrow functions in your js code to be safe, then this should work.

0
artur grzesiak On

I do not think what you are looking for is achievable with namespaces. But namespaces are a legacy feature from very early days of Typescript and their usage is (strongly) discouraged - from official docs:

[...] we recommended modules over namespaces in modern code.

and shortly again:

Thus, for new projects modules would be the recommended code organization mechanism.

In case of providing type definition removing usage of namespaces should be relatively straightforward.

The easiest option would be to declare exported objects as such by directly declaring their types. In case of Frontend it would look something like that:

const Frontend: {
    // in the main scope & Frontend
    // redeclared with typeof
    change: typeof change;
    emptyChange: typeof emptyChange;
    from: typeof from;
    getActorId: typeof getActorId;
    getConflicts: typeof getConflicts;
    getLastLocalChange: typeof getLastLocalChange;
    getObjectById: typeof getObjectById;
    getObjectId: typeof getObjectId;
    init: typeof init;

    // in Frontend only
    // declaration from scratch
    applyPatch<T>(
      doc: Doc<T>,
      patch: Patch,
      backendState?: BackendState
    ): Doc<T>;
    getBackendState<T>(doc: Doc<T>): BackendState;
    getElementIds(list: any): string[];
    setActorId<T>(doc: Doc<T>, actorId: string): Doc<T>;
  };

The above is not ideal as you need to type the exported function name twice, which is a bit error prone, but for amount of types you are dealing with probably totally fine.

The other option is to use auxiliary module to first group relative function together, then re-export them from auxiliary module and re-import from the main module:

declare module "automerge/frontend" {
  export {
    change,
    emptyChange,
    from,
    getActorId,
    getConflicts,
    getLastLocalChange,
    getObjectById,
    getObjectId,
    init
  } from "automerge";
  import { Doc, Patch, BackendState } from "automerge";

  export function applyPatch<T>(
    doc: Doc<T>,
    patch: Patch,
    backendState?: BackendState
  ): Doc<T>;
  export function getBackendState<T>(doc: Doc<T>): BackendState;
  export function getElementIds(list: any): string[];
  export function setActorId<T>(doc: Doc<T>, actorId: string): Doc<T>;
}

declare module "automerge" {
  /* other stuff */
  import * as _Frontend from 'automerge/frontend'
  const Frontend: typeof _Frontend
  /* other stuff */
}

The above is a bit convoluted and rather inelegant due to circular nature of imports/exports. You could try to move all related functions to the module "automerge/frontend", but then you would need re-export them from there, which will change slightly semantics and all export will need to be explicit (prefixed with export keyword - for example: export type Doc<T> = FreezeObject<T>;).


As the most correct and future proof solution I could recommend refactoring the code into modules without any circular dependencies - probably it could require creating a common module for grouping shared types.

Btw. if you interested in any of the above options please let me know and I would happily create a PR and we could move a discussion there.

0
Mark Dolbyrev On

Something like this would partially help you:

declare module 'automerge' {
  
  namespace Frontend {
    function getObjectById<T>(doc: T, objectId: any): T;
  }
  
  const getObjectById: typeof Frontend.getObjectById;

}

Try it on playground

Pros:

  • Reduces the amount of code by reusing typing of already declared functions.

Cons:

  • Doesn't really eliminate the need of declaration const/function with the exact same name twice.
0
Nenad On

This is simplified example, but you can achieve no duplication this way:

// backend.d.ts
declare module "backend" {
    export function Subtract(a: number, b: number): number;
}

Then:

// foo.d.ts
declare module "foo" {
    export function Add(a: number, b: number): number;
    export * from "backend";
    export * as B from "backend";
}

And finally, the usage:

// main.ts
import * as foo from "foo";

foo.Add(1, 2); // defined only in the "foo".
foo.Subtract(1, 2); // "backend" function exposed in the root of "foo".
foo.B.Subtract(1, 2); // same "backend" function exposed in the "B" (namespace) of "foo".