Welcome to OStack Knowledge Sharing Community for programmer and developer-Open, Learning and Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
160 views
in Technique[技术] by (71.8m points)

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:

  • The type definitions should promote best practices for using this library. What I mean by this is that I will not bother to create definitions for methods that are flagged as @private or which are otherwise not intended/recommended for external use. Per the library documentation, getFessonia is the only public interface to this library. While there's nothing stopping a developer from importing FFmpegCommand directly, one shouldn't (because, for example, the config that would have been set in getFessonia won't have been set, and errors will likely result).
  • The type definitions should be useful. Downstream developers should be able to assign types to their variables à la:
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:

  • I don't think I need a definition for the function getConfig, especially since I don't want to promote its direct usage. Does it matter that lib/util/config has a default export? Should I just export the Config interface directly and re-export that from index.d.ts? Or maybe I'll delete the function definition and keep the Config interface under the namespace; that way, should getConfig become a public function in the future, I can just add the definition for the function.
  • Re-exporting under the getFessonia namespace is tedious and not especially elegant.
  • I could end up with a lot of nesting (and aliasing) under getFessonia. For example, the constructor for FFmpegOutput takes an argument which is really just a map of arguments for an internal class FFmpegOption, so downstream code could maybe end up looking something like:
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);
  • It's not very intuitive for the defintion of the argument to getFessonia and the shape of FFmpegOutput to be siblings.
  • I'm making up the FFmpeg namespace for organizational/naming-conflict-avoidance reasons.

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!


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Answer

0 votes
by (71.8m points)

@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.


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome to OStack Knowledge Sharing Community for programmer and developer-Open, Learning and Share
Click Here to Ask a Question

...