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:
@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).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:
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.getFessonia
namespace is tedious and not especially elegant.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);
getFessonia
and the shape of FFmpegOutput
to be siblings.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!
@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.