node.jstypescripttypestype-declaration

Adding dynamic properties to typescript namespace in a declarations file


I am trying to create a @types package for a JavaScript library by submitting a PR to the DefinetlyTyped repository. The shape of the JavaScript library for which i am providing said types is as follows.

const COMMANDS = [
  "about", "authorize", "backend", "cat", "check", "checksum", "cleanup",
  "config", "config create", "config delete", "config disconnect", "config dump",
  "config edit", "config file", "config password", "config providers",
  /* A lot more commands which have been ommitted here */
];

const api = function(...args) {
  // Omitting irrelevant implementation
}

const promises = api.promises = function(...args) {
  // Omitting irrelevant implementation
}

COMAMNDS.forEach(command => {
  Object.defineProperty(api, commandName, {
    value: function(...args) {
      return api(commandName, ...args);
    }
  });

  Object.defineProperty(api.promises, command, {
    value: function(...args) {
      return promises(commandName, ...args);
    }
  });
});

module.exports = api;

Another constraint to bear in mind is that because of the use of module.exports above the checks for DefinitelyTyped insist that i make use of export = api syntax as opposed to the export default api syntax, (kindly please give this consideration inside the answers that you provide). The following has been my attempt thus far.

/// <reference types="node" />

import { ChildProcess } from "child_process";

declare const COMMANDS: [
  "about", "authorize", "backend", "cat", "check", "checksum", "cleanup",
  "config", "config create", "config delete", "config disconnect", "config dump",
  "config edit", "config file", "config password", "config providers",
  /* A lot more commands which have been ommitted here */
];

type CommandsUnion = typeof COMMANDS[number];

declare function api(
  ...args: (string | Object)[]
): ChildProcess;

declare namespace api {
  type PromiseApiFn = (
    ...args: (string | Object)[]
  ) => Promise<Buffer>;

  const promises: PromiseApiFn & {
    [Command in CommandsUnion]: PromiseApiFn
  };
}

export = api;

As you can gather from the above declaration file. I have failed to add the properties on api that are present in the COMMANDS array. This is the principal blocker that I am experiencing. Can you please suggest a work around for this or perhaps a different way to go about providing declarations for this library such that this issue does not arise.


Solution

  • Note, this edit introduces some annoyances I wrote about avoiding in explanations from the first revision of this answer. My original answer used ES module syntax in the declaration file, which is apparently unsupported by DefinitelyTyped (see the quoted documentation at the end of this answer).

    It seems like you have a lot of it figured out. I'm going to address the outstanding concerns separately — however — here's a link to the TS Playground with the full example: TS Playground

    For reference, here's a link to the source JS file that you shared with me in a comment, but the link is pinned to the version at the latest commit at the time of writing this answer: https://github.com/sntran/rclone.js/blob/8bd4e08afed96f44bd287e34068dfbf071095c9f/rclone.js


    import type { ChildProcess } from 'node:child_process';
    //     ^^^^
    

    I'm not 100% positive that it matters in a declaration file (since it deals with only types, not data), but it's always a good idea to use type modifiers on type imports.


    type Command = (
      | 'about' // Get quota information from the remote.
      | 'authorize' // Remote authorization.
      | 'backend' // Run a backend specific command.
      | 'cat' // Concatenates any files and sends them to stdout.
      | 'check' // Checks the files in the source and destination match.
    
    // --- snip ---
    
      | 'sync' // Make source and dest identical, modifying destination only.
      | 'test' // Run a test command.
      | 'touch' // Create new file or change file modification time.
      | 'tree' // List the contents of the remote in a tree like fashion.
      | 'version' // Show the version number.
    );
    

    This is the string literal union in your question, but without the data structure (which is unused). Note that I kept the inline comments from the module. Consumers might reference your types (but never the source code), so including those comments in your declaration file would be very helpful in that case. A declaration file is a documentation file that's in a format that is readable by the TS compiler.


    type FnStringOrObjectArgs<R> = (...args: (string | object)[]) => R;
    
    type ApiFn = FnStringOrObjectArgs<ChildProcess>;
    // Expands to: (...args: (string | object)[]) => ChildProcess
    
    type PromisesFn = FnStringOrObjectArgs<Promise<Buffer>>;
    // Expands to: (...args: (string | object)[]) => Promise<Buffer>
    

    The FnStringOrObjectArgs<R> utility produces the base function signature used by this module. The other function types are derived from it generically. You'll see type utilities like this in other libraries: they can help cut down on repetition when you have modular/derived types. This module doesn't have many types, so it's not really necessary, but I wanted to mention the pattern.

    Note: I chose arbitrary names for those types — the names you choose are obviously up to you.


    Now, for the type of api:

    /**
     * Spawns a rclone process to execute with the supplied arguments.
     *
     * The last argument can also be an object with all the flags.
     *
     * Options for the child process can also be passed into this last argument,
     * and they will be picked out.
     *
     * @param args arguments for the API call.
     * @returns the rclone subprocess.
     */
    declare const api: ApiFn & Record<Command, ApiFn> & {
      /** Promise-based API. */
      promises: PromisesFn & Record<Command, PromisesFn>;
    };
    

    TS consumers won't get the JSDoc from the source JS file, so including it is essential.

    Below I'll discuss mapped types using the type utility Record<Keys, Type>:

    First, let's look at the type of the promises property:

    PromisesFn & Record<Command, PromisesFn>
    

    The PromisesFn type was defined above, so this type is the intersection (&) of that function and an object whose keys are of type Command and whose values are of type PromisesFn. This results in the following behavior:

    api.promises.about; // PromisesFn
    api.promises.authorize; // PromisesFn
    api.promises.backend; // PromisesFn
    // ...etc.
    

    and we can try to use them without error:

    api.promises('authorize', 'something'); // Promise<Buffer>
    api.promises.authorize('something'); // Promise<Buffer>
    
    api.promises('authorize', {value: 'something'}); // Promise<Buffer>
    api.promises.authorize({value: 'something'}); // Promise<Buffer>
    

    Trying these invocations cause diagnostic errors:

    api.promises('authorize', 100); /*
                              ~~~
    Argument of type 'number' is not assignable to parameter of type 'string | object'.(2345) */
    
    api.promises.authorize(100); /*
                           ~~~
    Argument of type 'number' is not assignable to parameter of type 'string | object'.(2345) */
    

    which is great: exactly as intended!

    Now, with that done, understanding the type of api is trivial:

    ApiFn & Record<Command, ApiFn> & {
      /** Promise-based API. */
      promises: PromisesFn & Record<Command, PromisesFn>;
    }
    

    It's the intersection of

    Let's check a few properties:

    api.about; // ApiFn
    api.authorize; // ApiFn
    

    and invocations:

    api('authorize', 'something'); // ChildProcess
    api.authorize('something'); // ChildProcess
    
    api('authorize', {value: 'something'}); // ChildProcess
    api.authorize({value: 'something'}); // ChildProcess
    

    looks good.


    Now, for the exports. This is where it might seem a bit complicated (it is).

    The JS module is in CommonJS format and uses this syntax for its exports:

    module.exports = api;
    

    This pattern cannot be represented statically because it creates a module which is in the shape of api itself: it has all the dynamically-assigned and typed properties of api, and is also a function.

    The syntax to export this is archaic, but simple:

    export = api;
    

    Note that it also prevents writing other export statements in your file, even exporting of types — and my current understanding is that complex object types like api in this example, which is created from utility expressions, can't participate in declaration merging, so I don't know of a way to include the type alises in the exports. (Maybe someone else can comment with a solution to that.)

    Here's more detail from the TS handbook on module declaration files and default exports:

    One style of exporting in CommonJS is to export a function. Because a function is also an object, then extra fields can be added and are included in the export.

    function getArrayLength(arr) {
      return arr.length;
    }
    getArrayLength.maxInterval = 12;
    
    module.exports = getArrayLength;
    

    Which can be described with:

    export default function getArrayLength(arr: any[]): number;
    export const maxInterval: 12;
    

    Note that using export default in your .d.ts files requires esModuleInterop: true to work. If you can’t have esModuleInterop: true in your project, such as when you’re submitting a PR to Definitely Typed, you’ll have to use the export= syntax instead. This older syntax is harder to use but works everywhere. Here’s how the above example would have to be written using export=:

    declare function getArrayLength(arr: any[]): number;
    declare namespace getArrayLength {
      declare const maxInterval: 12;
    }
    
    export = getArrayLength;
    

    See Module: Functions for details of how that works, and the Modules reference page.