typescripttype-definition

How to organize Typescript type definitions for a function module with lots of private classes?


I'm writing type definitions for a library I don't own called fessonia. I have some experience doing this, but this library is organized differently than others I've worked with, and I'm not sure how to approach it.

This library's index.js is small:

const getFessonia = (opts = {}) => {
  require('./lib/util/config')(opts);
  const Fessonia = {
    FFmpegCommand: require('./lib/ffmpeg_command'),
    FFmpegInput: require('./lib/ffmpeg_input'),
    FFmpegOutput: require('./lib/ffmpeg_output'),
    FilterNode: require('./lib/filter_node'),
    FilterChain: require('./lib/filter_chain')
  };
  return Fessonia;
}

module.exports = getFessonia;

It exports a function which returns an object, each member of which is a class. (Every file I've encountered in the lib so far uses default exports.) I've started with the module function template, but I'm struggling to find harmony among some of my guiding principles:

import getFessonia from '@tedconf/fessonia';
// note the type assignment
const config: getFessonia.ConfigOpts = {
    debug: true,
};
const { FFmpegCommand, FFmpegInput, FFmpegOutput } = getFessonia(config);

So far the approach I've taken is to create .d.ts files for each .js file required to make useful type definitions, then import those into index.d.ts and re-export as needed in the getFessonia namespace. For example, in order to provide a type definition for the opts argument, I needed to read lib/util/config, which has a default export getConfig. Its type file ends up looking something like this:

import getLogger from './logger';

export = getConfig;

/**
 * Get the config object, optionally updated with new options
 */
declare function getConfig(options?: Partial<getConfig.Config>): getConfig.Config;

declare namespace getConfig {
    export interface Config {
      ffmpeg_bin: string;
      ffprobe_bin: string;
      debug: boolean;
      log_warnings: boolean;
      logger: getLogger.Logger;
    }
}

... and I use it in index.d.ts like this:

import getConfig from './lib/util/config';

export = getFessonia;

/**
 * Main function interface to the library. Returns object of classes when called.
 */
declare function getFessonia(opts?: Partial<getFessonia.ConfigOpts>): getFessonia.Fessonia;

declare namespace getFessonia {
    export interface Fessonia {
        // TODO
        FFmpegCommand: any;
        FFmpegInput: any;
        FFmpegOutput: any;
        FilterNode: any;
        FilterChain: any;
    }
    // note I've just aliased and re-exported this
    export type ConfigOpts = Partial<getConfig.Config>;
}

Reasons I think I might be headed down the wrong path:

import getFessonia from '@tedconf/fessonia';

const { FFmpegCommand, FFmpegInput, FFmpegOutput } = getFessonia();
// note the deep nesting
const outputOptions: getFessonia.FFmpeg.Output.Options = { /* some stuff */ };
const output = new FFmpegOutput('some/path', outputOptions);

You made it to the end! Thanks for reading this far. While I suspect there isn't one "right" answer, I look forward to reading about approaches others have taken, and I'm happy to be pointed to articles or relevant code repositories where I might learn by example. Thanks!


Solution

  • @alex-wayne's comment helped reset my brain. Thank you.

    For some reason I was writing my type definitions as though the library's usage of default exports meant I couldn't also export other things from my .d.ts files. Not enough sleep, maybe!

    Anyway, in addition to default-exporting the function getFessonia I ended up exporting an interface Fessonia to describe the return value as well as a namespace of the same name (more on TypeScript's combining behavior) to provide types for getFessonia's options as well as various other entities provided by the library. index.d.ts ended up looking like:

    import { FessoniaConfig } from './lib/util/config';
    import FFmpegCommand from './lib/ffmpeg_command';
    import FFmpegInput from './lib/ffmpeg_input';
    import FFmpegOutput from './lib/ffmpeg_output';
    import FilterChain from './lib/filter_chain';
    import FilterNode from './lib/filter_node';
    
    /** Main function interface to the library. Returns object of classes when called. */
    export default function getFessonia(opts?: Partial<Fessonia.ConfigOpts>): Fessonia;
    
    export interface Fessonia {
      FFmpegCommand: typeof FFmpegCommand;
      FFmpegInput: typeof FFmpegInput;
      FFmpegOutput: typeof FFmpegOutput;
      FilterChain: typeof FilterChain;
      FilterNode: typeof FilterNode;
    }
    
    // re-export only types (i.e., not constructors) to prevent direct instantiation
    import type FFmpegCommandType from './lib/ffmpeg_command';
    import type FFmpegError from './lib/ffmpeg_error';
    import type FFmpegInputType from './lib/ffmpeg_input';
    import type FFmpegOutputType from './lib/ffmpeg_output';
    import type FilterNodeType from './lib/filter_node';
    import type FilterChainType from './lib/filter_chain';
    export namespace Fessonia {
        export type ConfigOpts = Partial<FessoniaConfig>;
    
        export {
          FFmpegCommandType as FFmpegCommand,
          FFmpegError,
          FFmpegInputType as FFmpegInput,
          FFmpegOutputType as FFmpegOutput,
          FilterChainType as FilterChain,
          FilterNodeType as FilterNode,
        };
    }
    

    For the classes that are part of the Fessonia object, my general approach was to create a type definition for each one (leaving out private members) and export it. If the class functions had parameters with complex types, I'd create definitions for those and export them in a namespace with the same name as the class, e.g.:

    // abridged version of types/lib/ffmpeg_input.d.ts
    export default FFmpegInput;
    
    declare class FFmpegInput {
        constructor(url: FFmpegInput.UrlParam, options?: FFmpegInput.Options);
    }
    
    declare namespace FFmpegInput {
        export type Options = Map<string, FFmpegOption.OptionValue> | { [key: string]: FFmpegOption.OptionValue };
        export type UrlParam = string | FilterNode | FilterChain | FilterGraph;
    }
    

    Because of the re-exporting of the types at the bottom of index.d.ts, this downstream code becomes possible:

    import getFessonia, { Fessonia } from '@tedconf/fessonia';
    
    const { FFmpegCommand, FFmpegInput, FFmpegOutput } = getFessonia();
    // note the type assignment
    const outputOptions: Fessonia.FFmpegOutput.Options = { /* some stuff */ };
    const output = new FFmpegOutput('some/path', outputOptions);
    const cmd = new FFmpegCommand(commandOpts);
    

    While this isn't drastically different from what I had originally, it does feel like an improvement. I didn't have to invent too many new organizational structures; the type names are consistent with the structure of the codebase (with the addition of the Fessonia namespace). And it's readable.

    My first pass at typing this library is available on GitHub.

    Thanks to everyone who commented and got me thinking differently.