Reflection in JavaScript and TypeScript: writing a CLI framework
Javascript, as most dynamically-typed languages, has a lot of ways to inspect its values at runtime - getting their types, querying object fields, constructors, prototypes, et cetera. In this article I will give an overview of such techniques, and then show how using TypeScript allows for even more powerful reflection using decorators and type metadata.
I will demonstrate all of those by writing a toy CLI framework. By the end, its API will look something like this:
I will structure this whole thing with "levels", starting with:
#Level 0: no reflection
To start, let's try to write our toy CLI framework without using any reflection at all. It will basically be a simple wrapper over Node's util.parseArgs
.
stage0/framework.ts
import { parseArgs } from "node:util";
export type Main = (
args: string[],
opts: Record<string, OptionValue>
) => void | number | Promise<void | number>;
export type OptionValue =
| undefined
| boolean
| string
| Array<boolean | string>;
export async function run(main: Main) {
const { positionals: args, values: opts } = parseArgs({ strict: false });
try {
const code = await main(args, opts);
process.exitCode = code ?? 0;
} catch (error: any) {
process.exitCode = error.exitCode ?? 1;
throw error;
}
}
Using it will look something like this:
stage0/main.ts
import { OptionValue, run } from "./framework.js";
await run(main);
function main(args: string[], opts: Record<string, OptionValue>) {
if (opts.verbose || opts.v) {
console.debug(args);
console.debug(opts);
}
const [command, ...commandArgs] = args;
if (!command) {
console.error("no command specified");
return 1;
}
switch (command) {
case "hello": {
const [name] = commandArgs;
if (!name) {
console.error(`command required 1 argument, 0 given`);
return 1;
}
const enthusiastic = opts.enthusiastic ?? opts.e ?? false;
if (typeof enthusiastic !== "boolean") {
console.error(`invalid type for --enthusiastic option`);
return 1;
}
console.log(`Hello ${name}${enthusiastic ? "!" : "."}`);
return 0;
}
default:
console.error(`unknown command: ${command}`);
return 1;
}
}
As you can see, this "framework" is extremely bare-bones. Short options, option value types, command dispatch - all of that has to be manually implemented. It's not a limitation of parseArgs
- in fact, it accepts a detailed enough CLI definition that has most of those features. The thing I want to do, though, is to generate all that automatically, using reflection.
Anyway, let's start with the basics.
#Level 1: the basics of JS reflection
These things are basic enough that people usually don't event call them "reflection":
-
the typeof
operator: querying JS types of values
The typeof x
expression returns one of "undefined"
, "boolean"
, "number"
, "bigint"
, "string"
, "object"
, or "function"
. One caveat is: typeof null === "object"
, for historical reasons. Additionaly, it returns "function"
for class constructors, even though they can only be called with "new"
, and not as plain functions.
-
the instanceof
operator: querying an object's prototype chain
Speaking in terms of class-based inheritance, x instanceof A
returns true
if x
is an instance (duh) of A
or any of its subclasses.
-
the in
operator: querying whether an object has a property
The "p" in x
experssion returns true
if x
has a property named p
. Note that x
has to be an object - else a TypeError
is thrown.
-
the Object.keys()
function and the for...in
loop: enumerating an object's properties
Some of the properties might be non-enumerable, thus not showing up when using Object.keys()
or for...in
. Most "regular" object properties are enumerable, and I'll cover some exceptions later.
Let's apply some of the listed things to make our framework's API a little bit nicer. For example, let's make it so the program's entry point is a class, and its fields will represent command options:
stage1/framework.ts
import { parseArgs } from "node:util";
export interface Program {
main(args: string[], opts: Record<string, OptionValue>): void | number | Promise<void | number>;
}
export type OptionValue = undefined | boolean | string | Array<boolean | string>;
export async function run(Program: new () => Program) {
const program = new Program();
const { positionals: args, values: opts } = parseArgs({ strict: false });
for (const k of Object.keys(program)) {
if (k in opts) {
(program as any)[k] = opts[k];
delete opts[k];
}
}
try {
const code = await program.main(args, opts);
process.exitCode = code ?? 0;
} catch (error: any) {
process.exitCode = error.exitCode ?? 1;
throw error;
}
}
The main.ts
code now looks like this:
stage1/main.ts
import { OptionValue, run } from "./framework.js";
class Program {
verbose: boolean | undefined = undefined;
v: boolean | undefined;
main(args: string[], opts: Record<string, OptionValue>) {
const verbose = this.verbose ?? this.v ?? false;
if (verbose) {
console.debug(this);
console.debug(args);
console.debug(opts);
}
const [command, ...commandArgs] = args;
if (!command) {
console.error("no command specified");
return 1;
}
switch (command) {
case "hello": {
const [name] = commandArgs;
if (!name) {
console.error(`command required 1 argument, 0 given`);
return 1;
}
const enthusiastic = opts.enthusiastic ?? opts.e ?? false;
if (typeof enthusiastic !== "boolean") {
console.error(`invalid type for --enthusiastic option`);
return 1;
}
console.log(`Hello ${name}${enthusiastic ? "!" : "."}`);
return 0;
}
default:
console.error(`unknown command: ${command}`);
return 1;
}
}
}
await run(Program);
Well, it does not look that much nicer as of right now. Commands, for example, still have to be dispatched manually. But we'll fix that with the next level of reflection!
#Level 2: object prototypes, enumerating methods
Let's make it so any method of Program
is treated as a separate command.
Insance methods in JavaScript are simply properties of its prototype, the values of which are functions. All objects of class A
share the same prototype, which can be accessed as A.prototype
.
Thing is, though, that those prototype properties are marked as non-enumerable, meaning we can't just use Object.keys()
or a for...in
loop. For that, there is the Object.getOwnPropertyNames()
function. The Own
in the name means that it will only return the keys of this exact object, not of its prototypes. Which means that in order to handle the methods of Program
's possible superclasses, we will have to walk the prototype chain ourselves - like this:
const allKeys = [];
for (let proto = A.prototype; proto; proto = Object.getPrototypeOf(proto)) {
allKeys.push(...Object.getOwnPropertyNames(proto));
}
For our toy example, though, let's simply say that only Program
's own methods will be treated as commands. And let's also not forget to filter out constructor
from the list of the prototype's properties.
stage2/framework.ts
import { parseArgs } from "node:util";
export type CommandFn = (
args: string[],
opts: Record<string, OptionValue>
) => void | number | Promise<void | number>;
export type OptionValue = undefined | boolean | string | Array<boolean | string>;
export async function run(Program: new () => any) {
const program = new Program();
const {
positionals: [command, ...args],
values: opts,
} = parseArgs({ strict: false });
const sharedOpts = Object.keys(program);
for (const k of sharedOpts) {
if (k in opts) {
program[k] = opts[k];
delete opts[k];
}
}
const commands = Object.getOwnPropertyNames(Program.prototype).filter(
(k) => typeof program[k] === "function" && k !== "constructor"
);
if (!command) {
console.error("no command specified");
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
if (!commands.includes(command)) {
console.error(`unknown command: ${command}`);
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
try {
const code = await program[command]!(args, opts);
process.exitCode = code ?? 0;
} catch (error: any) {
process.exitCode = error.exitCode ?? 1;
throw error;
}
}
This allows us to finally get rid of command dispatch code in main.ts
:
stage2/main.ts
import { OptionValue, run } from "./framework.js";
class Program {
verbose: boolean | undefined;
v: boolean | undefined;
hello(args: string[], opts: Record<"e" | "enthusiastic", OptionValue>) {
const verbose = this.verbose ?? this.v ?? false;
if (verbose) {
console.debug(this);
console.debug(args);
console.debug(opts);
}
const enthusiastic = opts.enthusiastic ?? opts.e ?? false;
if (typeof enthusiastic !== "boolean") {
console.error(`invalid type for --enthusiastic option`);
return 1;
}
const [name] = args;
if (!name) {
console.error(`command required 1 argument, 0 given`);
return 1;
}
console.log(`Hello ${name}${enthusiastic ? "!" : "."}`);
return 0;
}
}
await run(Program);
#Level 3: function arguments
It would be nice to not have to parse the args
ourselves, but instead to force the framework to do that and to validate the number of arguments. For that, we'll change the interface of command methods a bit, passing args
as separate arguments, and putting the opts
as the first argument:
hello(args: string[], opts: Record<string, OptionValue>): ...
hello(opts: Record<string, OptionValue>, name: string): ...
This allows us to validate the number of CLI arguments based on the number of command functions' arguments - which can be queried by using the f.length
property.
There is a caveat, though. The f.length
property is, in fact, a minimum required number of arguments! It does not count any optional arguments:
function f1(a, b = null) {}
assert(f1.length === 1);
function f2(a, ...bs) {}
assert(f2.length === 1);
function f3(a) {
doWork(arguments[2]);
}
assert(f3.length === 1);
function f4(a) {}
f4(1, 2, 3, 4, 5);
Being mindful of that, let's implement the validation of the minimum number of CLI arguments;
stage3/framework.ts
import { parseArgs } from "node:util";
export type CommandFn = (
opts: Record<string, OptionValue>,
...args: string[]
) => void | number | Promise<void | number>;
export type OptionValue = undefined | boolean | string | Array<boolean | string>;
export async function run(Program: new () => any) {
const program = new Program();
const {
positionals: [command, ...args],
values: opts,
} = parseArgs({ strict: false });
const sharedOpts = Object.keys(program);
for (const k of sharedOpts) {
if (k in opts) {
program[k] = opts[k];
delete opts[k];
}
}
const commands = Object.getOwnPropertyNames(Program.prototype).filter(
(k) => typeof program[k] === "function" && k !== "constructor"
);
if (!command) {
console.error("no command specified");
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
if (!commands.includes(command)) {
console.error(`unknown command: ${command}`);
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
const commandFn: Function = program[command];
const minArgCount = commandFn.length - 1;
if (args.length < minArgCount) {
console.error(`too few arguments for command ${command}`);
console.error(`at least ${minArgCount}, ${args.length} given`);
process.exitCode = 1;
return;
}
try {
const code = await program[command]!(opts, ...args);
process.exitCode = code ?? 0;
} catch (error: any) {
process.exitCode = error.exitCode ?? 1;
throw error;
}
}
stage3/main.ts
import { OptionValue, run } from "./framework.js";
class Program {
verbose: boolean | undefined;
v: boolean | undefined;
hello(opts: Record<"e" | "enthusiastic", OptionValue>, name: string) {
const verbose = this.verbose ?? this.v ?? false;
if (verbose) {
console.debug(this);
console.debug([name]);
console.debug(opts);
}
const enthusiastic = opts.enthusiastic ?? opts.e ?? false;
if (typeof enthusiastic !== "boolean") {
console.error(`invalid type for --enthusiastic option`);
return 1;
}
console.log(`Hello ${name}${enthusiastic ? "!" : "."}`);
return 0;
}
}
await run(Program);
To make our framework understand short options, we need a way to specify those short names as additional metadata beside the option field, possibly with some extra option metadata. It will also allow us to explicitly mark option fields and command methods, allowing Program
to have fields and options that aren't part of the CLI.
The most convenient way to do that is to use decorators. In TypeScript, there actuall are two decorator implementations:
For one of the further reflection levels, we'll in fact have to use the "older" implementation. But for now, we can abstract those away completely using the reflect-metadata
library:
import "reflect-metadata";
class Foo {
@Reflect.metadata("meta-key", "value")
f() {}
}
const MyDecorator = (value) =>
Reflect.metadata(MyDecorator, value);
class Bar {
@MyDecorator("value")
prop: string;
}
const value1 = Reflect.getMetadata(Foo.prototype, "meta-key", "f");
const value2 = Reflect.getMetadata(Bar.prototype, MyDecorator, "prop");
Let's implement the short options feature with decorators and reflect-metadata
:
stage4/framework.ts
import "reflect-metadata";
import { parseArgs } from "node:util";
export interface OptionDefinition {
short?: string;
}
export const Option = (def: OptionDefinition) => Reflect.metadata(Option, def);
const getOptionMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Option, ctor.prototype, prop) as OptionDefinition | undefined;
export const Command = () => Reflect.metadata(Command, {});
const getCommandMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Command, ctor.prototype, prop) as {} | undefined;
export type CommandFn = (
opts: Record<string, OptionValue>,
...args: string[]
) => void | number | Promise<void | number>;
export type OptionValue = undefined | boolean | string | Array<boolean | string>;
export async function run(Program: new () => any) {
const program = new Program();
const {
positionals: [command, ...args],
values: opts,
} = parseArgs({ strict: false });
for (const k of Object.keys(program)) {
const def = getOptionMetadata(Program, k);
if (!def) continue;
if (def.short && def.short in opts) {
program[k] = opts[def.short];
delete opts[def.short];
}
if (k in opts) {
program[k] = opts[k];
delete opts[k];
}
}
const commands: string[] = [];
for (const k of Object.getOwnPropertyNames(Program.prototype)) {
const def = getCommandMetadata(Program, k);
if (!def) continue;
commands.push(k);
}
if (!command) {
console.error("no command specified");
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
if (!commands.includes(command)) {
console.error(`unknown command: ${command}`);
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
const commandFn: Function = program[command];
const minArgCount = commandFn.length - 1;
if (args.length < minArgCount) {
console.error(`too few arguments for command ${command}`);
console.error(`at least ${minArgCount}, ${args.length} given`);
process.exitCode = 1;
return;
}
try {
const code = await program[command]!(opts, ...args);
process.exitCode = code ?? 0;
} catch (error: any) {
process.exitCode = error.exitCode ?? 1;
throw error;
}
}
The main.ts
now looks like this:
stage4/main.ts
import { Command, Option, OptionValue, run } from "./framework.js";
class Program {
@Option({ short: "v" })
verbose = false;
version = "1.0.0";
@Command()
hello(opts: Record<"e" | "enthusiastic", OptionValue>, name: string) {
if (this.verbose) {
console.debug(this);
console.debug([name]);
console.debug(opts);
}
const enthusiastic = opts.enthusiastic ?? opts.e ?? false;
if (typeof enthusiastic !== "boolean") {
console.error(`invalid type for --enthusiastic option`);
return 1;
}
console.log(`Hello ${name}${enthusiastic ? "!" : "."}`);
return 0;
}
}
await run(Program);
#Level 5: runtime type descriptors
The next logical step is for the @Option()
decorator to also specify the type of the option's value. Thing is, JavaScript doesn't have a built-in way of representing types at runtime. There are the typeof
values, of course - but they aren't really useful for arrays and objects, as they don't specify the types of elements and members.
To combat that, there are a few common conventions. For example, Nest.js, among others, often uses these:
const number = Number;
const string = String;
const arrayOfNumber = [Number];
const arrayOfString = [String];
const person = {
name: String,
age: Number,
};
const dto = {
people: [{ name: String, age: Number }],
};
type Dto = {
people: { name: string; age: number }[];
};
It is important to distinguish between these and the actual TypeScript types. For example, defining a field in a TypeScript interface as Number
instead of number
can lead to a non-obvious errors - a primitive type can be assigned to a boxed type variable, but not the other way around!
Let's now use this "type descriptor" syntax to specify the types for our CLI options. We don't even have to implement the whole type hierarchy - parseArgs
only supports boolean
, string
, boolean[]
and string[]
:
stage5/framework.ts
import "reflect-metadata";
import { ParseArgsConfig, parseArgs } from "node:util";
export interface OptionDefinition {
short?: string;
type: typeof String | typeof Boolean | [typeof String] | [typeof Boolean];
}
export const Option = (def: OptionDefinition) => Reflect.metadata(Option, def);
const getOptionMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Option, ctor.prototype, prop) as OptionDefinition | undefined;
export const Command = () => Reflect.metadata(Command, {});
const getCommandMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Command, ctor.prototype, prop) as {} | undefined;
export type CommandFn = (
opts: Record<string, OptionValue>,
...args: string[]
) => void | number | Promise<void | number>;
export type OptionValue = undefined | boolean | string | Array<boolean | string>;
export async function run(Program: new () => any) {
const program = new Program();
const {
positionals: [command, ...args],
values: opts,
} = parseArgs({
strict: false,
options: getOptionsConfigFromMetadata(Program, program),
});
for (const k of Object.keys(program)) {
const def = getOptionMetadata(Program, k);
if (!def) continue;
if (def.short && def.short in opts) {
program[k] = opts[def.short];
delete opts[def.short];
}
if (k in opts) {
program[k] = opts[k];
delete opts[k];
}
}
const commands: string[] = [];
for (const k of Object.getOwnPropertyNames(Program.prototype)) {
const def = getCommandMetadata(Program, k);
if (!def) continue;
commands.push(k);
}
if (!command) {
console.error("no command specified");
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
if (!commands.includes(command)) {
console.error(`unknown command: ${command}`);
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
const commandFn: Function = program[command];
const minArgCount = commandFn.length - 1;
if (args.length < minArgCount) {
console.error(`too few arguments for command ${command}`);
console.error(`at least ${minArgCount}, ${args.length} given`);
process.exitCode = 1;
return;
}
try {
const code = await program[command]!(opts, ...args);
process.exitCode = code ?? 0;
} catch (error: any) {
process.exitCode = error.exitCode ?? 1;
throw error;
}
}
type OptionsConfig = Exclude<ParseArgsConfig["options"], undefined>;
function getOptionsConfigFromMetadata(Program: new () => any, program: any): OptionsConfig {
const config: OptionsConfig = {};
for (const k of Object.keys(program)) {
const def = getOptionMetadata(Program, k);
if (!def) continue;
let short = def.short;
let type: "string" | "boolean" = "string";
let multiple = false;
if (def.type === String) {
type = "string";
multiple = false;
} else if (def.type === Boolean) {
type = "boolean";
multiple = false;
} else if (Array.isArray(def.type)) {
multiple = true;
if (def.type[0] === String) {
type = "string";
} else if (def.type[0] === Boolean) {
type = "boolean";
}
}
config[k] = { short, type, multiple };
}
return config;
}
stage5/main.ts
import { Command, Option, OptionValue, run } from "./framework.js";
class Program {
@Option({ type: Boolean, short: "v" })
verbose = false;
version = "1.0.0";
@Command()
hello(opts: Record<"e" | "enthusiastic", OptionValue>, name: string) {
if (this.verbose) {
console.debug(this);
console.debug([name]);
console.debug(opts);
}
const enthusiastic = opts.enthusiastic ?? opts.e ?? false;
if (typeof enthusiastic !== "boolean") {
console.error(`invalid type for --enthusiastic option`);
return 1;
}
console.log(`Hello ${name}${enthusiastic ? "!" : "."}`);
return 0;
}
}
await run(Program);
#Level 6: making TypeScript do the work
When using TypeScript, it is possible to make it embed some type information into class member metadata. For that, we'll need:
We need those older decorators specifically - the now-standard ones, sadly, won't work.
The type metadata is only saved for class members, and only for those that have at least one decorator attached to them. The metadata format isn't well documented, but we can get an idea bu just messing around on the TS Playground.
Sadly, the metadata isn't as detailed as I'd want it to be. In essence, each type is represented by its "constructor" function - Number
for number
, Boolean
for boolean
, et cetera. That means that any object type (that is not a class itself) is just Object
and any array is just Array
- without any info on the type of elements or members.
All this means that TypeScript's type metadata is not enough to be the only source of type info for our options. But it would make the API nicer if we implement it as a possibility:
stage6/framework.ts
import "reflect-metadata";
import { ParseArgsConfig, parseArgs } from "node:util";
export interface OptionDefinition {
short?: string;
type?: typeof String | typeof Boolean | [typeof String] | [typeof Boolean];
}
export const Option = (def: OptionDefinition) => Reflect.metadata(Option, def);
const getOptionMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Option, ctor.prototype, prop) as OptionDefinition | undefined;
export const Command = () => Reflect.metadata(Command, {});
const getCommandMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Command, ctor.prototype, prop) as {} | undefined;
export type CommandFn = (
opts: Record<string, OptionValue>,
...args: string[]
) => void | number | Promise<void | number>;
export type OptionValue = undefined | boolean | string | Array<boolean | string>;
export async function run(Program: new () => any) {
const program = new Program();
const {
positionals: [command, ...args],
values: opts,
} = parseArgs({
strict: false,
options: getOptionsConfigFromMetadata(Program, program),
});
for (const k of Object.keys(program)) {
const def = getOptionMetadata(Program, k);
if (!def) continue;
if (def.short && def.short in opts) {
program[k] = opts[def.short];
delete opts[def.short];
}
if (k in opts) {
program[k] = opts[k];
delete opts[k];
}
}
const commands: string[] = [];
for (const k of Object.getOwnPropertyNames(Program.prototype)) {
const def = getCommandMetadata(Program, k);
if (!def) continue;
commands.push(k);
}
if (!command) {
console.error("no command specified");
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
if (!commands.includes(command)) {
console.error(`unknown command: ${command}`);
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
const commandFn: Function = program[command];
const minArgCount = commandFn.length - 1;
if (args.length < minArgCount) {
console.error(`too few arguments for command ${command}`);
console.error(`at least ${minArgCount}, ${args.length} given`);
process.exitCode = 1;
return;
}
try {
const code = await program[command]!(opts, ...args);
process.exitCode = code ?? 0;
} catch (error: any) {
process.exitCode = error.exitCode ?? 1;
throw error;
}
}
type OptionsConfig = Exclude<ParseArgsConfig["options"], undefined>;
function getOptionsConfigFromMetadata(Program: new () => any, program: any): OptionsConfig {
const config: OptionsConfig = {};
for (const k of Object.keys(program)) {
const def = getOptionMetadata(Program, k);
if (!def) continue;
const defType = def.type ?? Reflect.getMetadata("design:type", Program.prototype, k);
let short = def.short;
let type: "string" | "boolean" = "string";
let multiple = false;
if (defType === String) {
type = "string";
multiple = false;
} else if (defType === Boolean) {
type = "boolean";
multiple = false;
} else if (Array.isArray(defType)) {
multiple = true;
if (defType[0] === String) {
type = "string";
} else if (defType[0] === Boolean) {
type = "boolean";
}
} else {
throw new Error(`unable to determine option type for ${k}`);
}
config[k] = { short, type, multiple };
}
return config;
}
stage6/main.ts
import { Command, Option, OptionValue, run } from "./framework.js";
class Program {
@Option({ short: "v" })
verbose: boolean = false;
version = "1.0.0";
@Command()
hello(opts: Record<"e" | "enthusiastic", OptionValue>, name: string) {
if (this.verbose) {
console.debug(this);
console.debug([name]);
console.debug(opts);
}
const enthusiastic = opts.enthusiastic ?? opts.e ?? false;
if (typeof enthusiastic !== "boolean") {
console.error(`invalid type for --enthusiastic option`);
return 1;
}
console.log(`Hello ${name}${enthusiastic ? "!" : "."}`);
return 0;
}
}
await run(Program);
#Level 7: method argument types and DTO classes
The same emitDecoratorMetadata
feature will allow us to query types of class method arguments. We'll use that to finally get rid of the need to manually validate per-command options.
But to overcome the metadata limitations, we'll need to introduce DTO classes for those options:
interface IOptions {
enthusiastic: boolean;
}
class Options {
enthusiastic: boolean;
}
const opts1: Options = { enthusiastic: true };
const opts2: Options = JSON.parse(str);
assert(!(opts1 instanceof Options));
Let's add this final feature to our framework.
stage7/framework.ts
import "reflect-metadata";
import { ParseArgsConfig, parseArgs } from "node:util";
export interface OptionDefinition {
short?: string;
type?: typeof String | typeof Boolean | [typeof String] | [typeof Boolean];
}
export const Option = (def: OptionDefinition) => Reflect.metadata(Option, def);
const getOptionMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Option, ctor.prototype, prop) as OptionDefinition | undefined;
export const Command = () => Reflect.metadata(Command, {});
const getCommandMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Command, ctor.prototype, prop) as {} | undefined;
export type CommandFn = (
opts: Record<string, OptionValue>,
...args: string[]
) => void | number | Promise<void | number>;
export type OptionValue = undefined | boolean | string | Array<boolean | string>;
export async function run(Program: new () => any) {
const program = new Program();
const {
positionals: [command],
} = parseArgs({
strict: false,
options: getOptionsConfigFromMetadata(Program, program),
});
const commands: string[] = [];
for (const k of Object.getOwnPropertyNames(Program.prototype)) {
const def = getCommandMetadata(Program, k);
if (!def) continue;
commands.push(k);
}
if (!command) {
console.error("no command specified");
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
if (!commands.includes(command)) {
console.error(`unknown command: ${command}`);
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
const OptsDto = Reflect.getMetadata("design:paramtypes", Program.prototype, command)?.[0];
const optsDto = OptsDto ? new OptsDto() : undefined;
const {
positionals: [, ...args],
values: opts,
} = parseArgs({
strict: false,
options: {
...getOptionsConfigFromMetadata(Program, program),
...getOptionsConfigFromMetadata(OptsDto, optsDto),
},
});
Object.assign(optsDto, opts);
const commandFn: Function = program[command];
const minArgCount = commandFn.length - 1;
if (args.length < minArgCount) {
console.error(`too few arguments for command ${command}`);
console.error(`at least ${minArgCount}, ${args.length} given`);
process.exitCode = 1;
return;
}
try {
const code = await program[command]!(optsDto, ...args);
process.exitCode = code ?? 0;
} catch (error: any) {
process.exitCode = error.exitCode ?? 1;
throw error;
}
}
type OptionsConfig = Exclude<ParseArgsConfig["options"], undefined>;
function getOptionsConfigFromMetadata(Program: new () => any, program: any): OptionsConfig {
const config: OptionsConfig = {};
for (const k of Object.keys(program)) {
const def = getOptionMetadata(Program, k);
if (!def) continue;
const defType = def.type ?? Reflect.getMetadata("design:type", Program.prototype, k);
let short = def.short;
let type: "string" | "boolean" = "string";
let multiple = false;
if (defType === String) {
type = "string";
multiple = false;
} else if (defType === Boolean) {
type = "boolean";
multiple = false;
} else if (Array.isArray(defType)) {
multiple = true;
if (defType[0] === String) {
type = "string";
} else if (defType[0] === Boolean) {
type = "boolean";
}
} else {
throw new Error(`unable to determine option type for ${k}`);
}
config[k] = { short, type, multiple };
}
return config;
}
function extractOptions(Dto: new () => any, dto: any, opts: Record<string, OptionValue>): void {
for (const k of Object.keys(dto)) {
const def = getOptionMetadata(Dto, k);
if (!def) continue;
if (def.short && def.short in opts) {
dto[k] = opts[def.short];
delete opts[def.short];
}
if (k in opts) {
dto[k] = opts[k];
delete opts[k];
}
}
}
This is how our final API looks like in main.ts
:
import { Command, Option, run } from "./framework.js";
class HelloOptions {
@Option({ short: "e" })
enthusiastic: boolean = false;
}
class Program {
@Option({ short: "v" })
verbose: boolean = false;
version = "1.0.0";
@Command()
hello({ enthusiastic }: HelloOptions, name: string) {
if (this.verbose) {
console.debug(this);
console.debug([name]);
console.debug({ enthusiastic });
}
console.log(`Hello ${name}${enthusiastic ? "!" : "."}`);
return 0;
}
}
await run(Program);
#@Conclusion()
As this last bit of code (hopefully) demonstrates, reflection is a very powerful tool for designing nice-to-use APIs. A lot of TypeScript frameworks use those - Nest.js being my primary inspiration. I hope this brief overview of reflection techniques was helpful!