Reflection in JavaScript and TypeScript: writing a CLI framework
- 2023-11-23
- en
- CC BY-SA 4.0
- read in russian
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 =
// option is not specified
| undefined
// a boolean option, without a value
| boolean
// an option that has a value
| string
// an option specified multiple times
| 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>) {
// have to manually implement short options
if (opts.verbose || opts.v) {
console.debug(args);
console.debug(opts);
}
// have to manually parse commands
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;
}
// have to manually check option types
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
typeofoperator: querying JS types of valuesThe
typeof xexpression 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
instanceofoperator: querying an object's prototype chainSpeaking in terms of class-based inheritance,
x instanceof Areturnstrueifxis an instance (duh) ofAor any of its subclasses. -
the
inoperator: querying whether an object has a propertyThe
"p" in xexperssion returnstrueifxhas a property namedp. Note thatxhas to be an object - else aTypeErroris thrown. -
the
Object.keys()function and thefor...inloop: enumerating an object's propertiesSome of the properties might be non-enumerable, thus not showing up when using
Object.keys()orfor...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 });
// by convention, all the fields of `program` represent CLI options
// right now, those are shared between all commands
for (const k of Object.keys(program)) {
if (k in opts) {
// it's quite hard to write properly typed code when using reflection
// so be prepared for some `any`s
(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 {
// TypeScript's targets that are older than ES2022 won't have field definition syntax,
// which means that uninitialized field will be missing from the class,
// so I'm explicitly initializing this to `undefined`
verbose: boolean | undefined = undefined;
// with a target of ES2022 or newer, you can just do:
v: boolean | undefined;
main(args: string, opts: Record<string, OptionValue>) {
// still have to manually implement short options
const verbose = this.verbose ?? this.v ?? false;
if (verbose) {
console.debug(this);
console.debug(args);
console.debug(opts);
}
// still have to manually dispatch commands
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;
}
// still have to manually chech option types
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];
}
}
// by convention, all `program`'s instance methods define separate commands
// they are not enumerable, so we use getOwnPropertyNames() instead of keys()
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:
// Before:
hello(args: string[], opts: Record<string, OptionValue>): ...
// After:
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:
// arguments with a default value
function f1(a, b = null) {}
assert(f1.length === 1);
// rest-arguments
function f2(a, ...bs) {}
assert(f2.length === 1);
// the `arguments` property
function f3(a) {
doWork(arguments[2]);
}
assert(f3.length === 1);
// and the "extra" arguments at call site
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>,
// passing `args` as separate arguments
...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];
}
}
// by convention, all `program`'s instance methods define separate commands
// they are not enumerable, so we use getOwnPropertyNames() instead of keys()
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];
// the -1 is here because of the `opts` argument
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);#Level 4: decorators and Reflect.metadata
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:
- the now-ECMAScript-standard one, available by default since TypeScript 5.0
- the one based on a previous draft, available using the
experimentalDecoratorsoption
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";
// the global Reflect object now has some new methods
class Foo {
// you can use Reflect.metadata() itself as a decorator
@.metadata"meta-key", "value"
f() {}
}
// but it's better to wrap it into a function
const MyDecorator = (value) =>
// you can use this same function as a metadata key
Reflect.metadata(MyDecorator, value);
class Bar {
@MyDecorator"value"
prop: string;
}
// accessing the metadata
const value1 = Reflect.getMetadata(Foo.prototype, "meta-key", "f");
const value2 = Reflect.getMetadata(Bar.prototype, MyDecorator, "prop");
// if called without the last argument, it returns the metadata for the class itself,
// rather than its members
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;
}
// a Reflect.metadata-based decorator
// you can use the function itself as metadata key
export const Option = (def: OptionDefinition) => Reflect.metadata(Option, def);
// for convenience, let's also define a getter
const getOptionMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Option, ctor.prototype, prop) as OptionDefinition | undefined;
// even if the decorator has no arguments,
// it's more convenient to wrap it in a function anyway
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 {
// specifying short options with a decorator
@Option{: "v" }
verbose = false;
// no @Option() decorator means this is not an option
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);
}
// for now, command-specific options still have to be handled manually
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:
// primitive types are represented by their "constructors"
const number = Number;
const string = String;
// arrays are representet by one-element arrays
const arrayOfNumber = [Number];
const arrayOfString = [String];
// objects are represented by, well, objects
const person = {
name: String,
age: Number,
};
// you can combine those in any way you like
const dto = {
people: [{ name: String, age: Number }],
};
// it often resembles actual TypeScript type definitions
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;
}
}
// idk why, but this type isn't exported properly
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{: Boolean,: "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:
- to enable the
emitDecoratorMetadatacompiler option; - to enable "older"
experimentalDecorators; - to use our old friend
reflect-metadata.
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;
// getting the TypeScript type metadata
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 {
// you might have to specify the types explicitly for emitDecoratorMetadata to work
// even in cases where they are inferred correctly by the compiler
@Option{: "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:
// instead of this:
interface IOptions {
enthusiastic: boolean;
}
// we'll need this:
class Options {
enthusiastic: boolean;
}
// you can still use object literals with such types
const opts1: Options = { enthusiastic: true };
const opts2: Options = JSON.parse(str);
// but remember that they won't magically become instances of Options!
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";
// можно даже отметить этот класс как abstract
class HelloOptions {
@Option{: "e" }
enthusiastic: boolean = false;
}
class Program {
@Option{: "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!