0

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.

3
  • Can you link to the actual source? In the JS implementation code you posted, it seems that there are errors/bugs. For example, calling Object.entries(array) will return an array of [index, value] tuples, so the code you've shown is setting the properties to the stringified version of these tuples (tsplay.dev/m04rPm). I'm fairly certain this is not what's intended. Additionally, the omitted blocks are actually quite important. Commented Jul 14, 2022 at 19:40
  • @jsejcksn Sorry about that i guess i typed the question in a hurry, i have made the corrections above, you can find the original code at the following link. github.com/sntran/rclone.js/blob/main/rclone.js Commented Jul 15, 2022 at 11:38
  • Thanks. I see the issue that you opened in the repo. I plan to compose an answer in just a bit. Commented Jul 15, 2022 at 23:00

1 Answer 1

1
+100

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

  • ApiFn, and
  • an object with Command keys which have ApiFn values, and
  • an object with the property promises which has a value of the type PromisesFn & Record<Command, PromisesFn>

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.

Sign up to request clarification or add additional context in comments.

4 Comments

Wow Wow Wow !!! thank you very much ! This answer shows that there are still good people out there, In all fairness i think this answer deserves the bounty because it solves the primary issue of correctly shaping the library from the perspective of the ts compiler, while i do agree that not being able to export types from the declaration file does indeed present a problem. I believe that for now that should not be an issue. Thanks again this was really holding up a lot of other work
Glad it works for you: hopefully you feel stronger in TypeScript!
btw is it alright with you if i add you as a maintainer for this package on DefinetlyTyped ?
@AtifFarooq I don’t plan to maintain that library, so it wouldn’t be fitting to list me using that role. However, if you want to list me as a co-author in the commit or documentation, then you have my permission to do so. My GitHub account is linked in my Stack Overflow profile. Thanks for asking.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.