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";exporttypeMain=(args:string[],opts:Record<string,OptionValue>)=>void|number|Promise<void|number>;exporttypeOptionValue=// 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>;exportasyncfunctionrun(main:Main){const{positionals:args,values:opts}=parseArgs({strict:false});try{constcode=awaitmain(args,opts);process.exitCode=code??0;}catch(error:any){process.exitCode=error.exitCode??1;throwerror;}}
Using it will look something like this:
stage0/main.ts
import{OptionValue,run}from"./framework.js";awaitrun(main);functionmain(args:string[],opts:Record<string,OptionValue>){// have to manually implement short optionsif(opts.verbose||opts.v){console.debug(args);console.debug(opts);}// have to manually parse commandsconst[command,...commandArgs]=args;if(!command){console.error("no command specified");return1;}switch(command){case"hello":{const[name]=commandArgs;if(!name){console.error(`command required 1 argument, 0 given`);return1;}// have to manually check option typesconstenthusiastic=opts.enthusiastic??opts.e??false;if(typeofenthusiastic!=="boolean"){console.error(`invalid type for --enthusiastic option`);return1;}console.log(`Hello ${name}${enthusiastic?"!":"."}`);return0;}default:console.error(`unknown command: ${command}`);return1;}}
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 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.
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";exportinterfaceProgram{main(args:string[],opts:Record<string,OptionValue>):void|number|Promise<void|number>;}exporttypeOptionValue=undefined|boolean|string|Array<boolean|string>;exportasyncfunctionrun(Program:new()=>Program){constprogram=newProgram();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 commandsfor(constkofObject.keys(program)){if(kinopts){// it's quite hard to write properly typed code when using reflection// so be prepared for some `any`s(programasany)[k]=opts[k];deleteopts[k];}}try{constcode=awaitprogram.main(args,opts);process.exitCode=code??0;}catch(error:any){process.exitCode=error.exitCode??1;throwerror;}}
The main.ts code now looks like this:
stage1/main.ts
import{OptionValue,run}from"./framework.js";classProgram{// 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 optionsconstverbose=this.verbose??this.v??false;if(verbose){console.debug(this);console.debug(args);console.debug(opts);}// still have to manually dispatch commandsconst[command,...commandArgs]=args;if(!command){console.error("no command specified");return1;}switch(command){case"hello":{const[name]=commandArgs;if(!name){console.error(`command required 1 argument, 0 given`);return1;}// still have to manually chech option typesconstenthusiastic=opts.enthusiastic??opts.e??false;if(typeofenthusiastic!=="boolean"){console.error(`invalid type for --enthusiastic option`);return1;}console.log(`Hello ${name}${enthusiastic?"!":"."}`);return0;}default:console.error(`unknown command: ${command}`);return1;}}}awaitrun(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:
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";exporttypeCommandFn=(args:string[],opts:Record<string,OptionValue>)=>void|number|Promise<void|number>;exporttypeOptionValue=undefined|boolean|string|Array<boolean|string>;exportasyncfunctionrun(Program:new()=>any){constprogram=newProgram();const{positionals:[command,...args],values:opts,}=parseArgs({strict:false});constsharedOpts=Object.keys(program);for(constkofsharedOpts){if(kinopts){program[k]=opts[k];deleteopts[k];}}// by convention, all `program`'s instance methods define separate commands// they are not enumerable, so we use getOwnPropertyNames() instead of keys()constcommands=Object.getOwnPropertyNames(Program.prototype).filter((k)=>typeofprogram[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{constcode=awaitprogram[command]!(args,opts);process.exitCode=code??0;}catch(error:any){process.exitCode=error.exitCode??1;throwerror;}}
This allows us to finally get rid of command dispatch code in main.ts:
stage2/main.ts
import{OptionValue,run}from"./framework.js";classProgram{verbose:boolean|undefined;v:boolean|undefined;hello(args:string[],opts:Record<"e"|"enthusiastic",OptionValue>){constverbose=this.verbose??this.v??false;if(verbose){console.debug(this);console.debug(args);console.debug(opts);}constenthusiastic=opts.enthusiastic??opts.e??false;if(typeofenthusiastic!=="boolean"){console.error(`invalid type for --enthusiastic option`);return1;}const[name]=args;if(!name){console.error(`command required 1 argument, 0 given`);return1;}console.log(`Hello ${name}${enthusiastic?"!":"."}`);return0;}}awaitrun(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:
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 valuefunctionf1(a,b=null){}assert(f1.length===1);// rest-argumentsfunctionf2(a,...bs){}assert(f2.length===1);// the `arguments` propertyfunctionf3(a){doWork(arguments[2]);}assert(f3.length===1);// and the "extra" arguments at call sitefunctionf4(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";exporttypeCommandFn=(opts:Record<string,OptionValue>,// passing `args` as separate arguments...args:string[])=>void|number|Promise<void|number>;exporttypeOptionValue=undefined|boolean|string|Array<boolean|string>;exportasyncfunctionrun(Program:new()=>any){constprogram=newProgram();const{positionals:[command,...args],values:opts,}=parseArgs({strict:false});constsharedOpts=Object.keys(program);for(constkofsharedOpts){if(kinopts){program[k]=opts[k];deleteopts[k];}}// by convention, all `program`'s instance methods define separate commands// they are not enumerable, so we use getOwnPropertyNames() instead of keys()constcommands=Object.getOwnPropertyNames(Program.prototype).filter((k)=>typeofprogram[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;}constcommandFn:Function=program[command];// the -1 is here because of the `opts` argumentconstminArgCount=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{constcode=awaitprogram[command]!(opts,...args);process.exitCode=code??0;}catch(error:any){process.exitCode=error.exitCode??1;throwerror;}}
stage3/main.ts
import{OptionValue,run}from"./framework.js";classProgram{verbose:boolean|undefined;v:boolean|undefined;hello(opts:Record<"e"|"enthusiastic",OptionValue>,name:string){constverbose=this.verbose??this.v??false;if(verbose){console.debug(this);console.debug([name]);console.debug(opts);}constenthusiastic=opts.enthusiastic??opts.e??false;if(typeofenthusiastic!=="boolean"){console.error(`invalid type for --enthusiastic option`);return1;}console.log(`Hello ${name}${enthusiastic?"!":"."}`);return0;}}awaitrun(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:
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 methodsclassFoo{// you can use Reflect.metadata() itself as a decorator@Reflect.metadata("meta-key","value")f(){}}// but it's better to wrap it into a functionconstMyDecorator=(value)=>// you can use this same function as a metadata key
Reflect.metadata(MyDecorator,value);classBar{@MyDecorator("value")prop:string;}// accessing the metadataconstvalue1=Reflect.getMetadata(Foo.prototype,"meta-key","f");constvalue2=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";exportinterfaceOptionDefinition{short?:string;}// a Reflect.metadata-based decorator// you can use the function itself as metadata keyexportconstOption=(def:OptionDefinition)=>Reflect.metadata(Option,def);// for convenience, let's also define a getterconstgetOptionMetadata=(ctor:new()=>any,prop:string)=>Reflect.getMetadata(Option,ctor.prototype,prop)asOptionDefinition|undefined;// even if the decorator has no arguments,// it's more convenient to wrap it in a function anywayexportconstCommand=()=>Reflect.metadata(Command,{});constgetCommandMetadata=(ctor:new()=>any,prop:string)=>Reflect.getMetadata(Command,ctor.prototype,prop)as{}|undefined;exporttypeCommandFn=(opts:Record<string,OptionValue>,...args:string[])=>void|number|Promise<void|number>;exporttypeOptionValue=undefined|boolean|string|Array<boolean|string>;exportasyncfunctionrun(Program:new()=>any){constprogram=newProgram();const{positionals:[command,...args],values:opts,}=parseArgs({strict:false});for(constkofObject.keys(program)){constdef=getOptionMetadata(Program,k);if(!def)continue;if(def.short&&def.shortinopts){program[k]=opts[def.short];deleteopts[def.short];}if(kinopts){program[k]=opts[k];deleteopts[k];}}constcommands:string[]=[];for(constkofObject.getOwnPropertyNames(Program.prototype)){constdef=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;}constcommandFn:Function=program[command];constminArgCount=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{constcode=awaitprogram[command]!(opts,...args);process.exitCode=code??0;}catch(error:any){process.exitCode=error.exitCode??1;throwerror;}}
The main.ts now looks like this:
stage4/main.ts
import{Command,Option,OptionValue,run}from"./framework.js";classProgram{// specifying short options with a decorator@Option({short:"v"})verbose=false;// no @Option() decorator means this is not an optionversion="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 manuallyconstenthusiastic=opts.enthusiastic??opts.e??false;if(typeofenthusiastic!=="boolean"){console.error(`invalid type for --enthusiastic option`);return1;}console.log(`Hello ${name}${enthusiastic?"!":"."}`);return0;}}awaitrun(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, oftenuses these:
// primitive types are represented by their "constructors"constnumber=Number;conststring=String;// arrays are representet by one-element arraysconstarrayOfNumber=[Number];constarrayOfString=[String];// objects are represented by, well, objectsconstperson={name:String,age:Number,};// you can combine those in any way you likeconstdto={people:[{name:String,age:Number}],};// it often resembles actual TypeScript type definitionstypeDto={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";exportinterfaceOptionDefinition{short?:string;type:typeofString|typeofBoolean|[typeofString]|[typeofBoolean];}exportconstOption=(def:OptionDefinition)=>Reflect.metadata(Option,def);constgetOptionMetadata=(ctor:new()=>any,prop:string)=>Reflect.getMetadata(Option,ctor.prototype,prop)asOptionDefinition|undefined;exportconstCommand=()=>Reflect.metadata(Command,{});constgetCommandMetadata=(ctor:new()=>any,prop:string)=>Reflect.getMetadata(Command,ctor.prototype,prop)as{}|undefined;exporttypeCommandFn=(opts:Record<string,OptionValue>,...args:string[])=>void|number|Promise<void|number>;exporttypeOptionValue=undefined|boolean|string|Array<boolean|string>;exportasyncfunctionrun(Program:new()=>any){constprogram=newProgram();const{positionals:[command,...args],values:opts,}=parseArgs({strict:false,options:getOptionsConfigFromMetadata(Program,program),});for(constkofObject.keys(program)){constdef=getOptionMetadata(Program,k);if(!def)continue;if(def.short&&def.shortinopts){program[k]=opts[def.short];deleteopts[def.short];}if(kinopts){program[k]=opts[k];deleteopts[k];}}constcommands:string[]=[];for(constkofObject.getOwnPropertyNames(Program.prototype)){constdef=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;}constcommandFn:Function=program[command];constminArgCount=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{constcode=awaitprogram[command]!(opts,...args);process.exitCode=code??0;}catch(error:any){process.exitCode=error.exitCode??1;throwerror;}}// idk why, but this type isn't exported properlytypeOptionsConfig=Exclude<ParseArgsConfig["options"],undefined>;functiongetOptionsConfigFromMetadata(Program:new()=>any,program:any):OptionsConfig{constconfig:OptionsConfig={};for(constkofObject.keys(program)){constdef=getOptionMetadata(Program,k);if(!def)continue;letshort=def.short;lettype:"string"|"boolean"="string";letmultiple=false;if(def.type===String){type="string";multiple=false;}elseif(def.type===Boolean){type="boolean";multiple=false;}elseif(Array.isArray(def.type)){multiple=true;if(def.type[0]===String){type="string";}elseif(def.type[0]===Boolean){type="boolean";}}config[k]={short,type,multiple};}returnconfig;}
stage5/main.ts
import{Command,Option,OptionValue,run}from"./framework.js";classProgram{@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);}constenthusiastic=opts.enthusiastic??opts.e??false;if(typeofenthusiastic!=="boolean"){console.error(`invalid type for --enthusiastic option`);return1;}console.log(`Hello ${name}${enthusiastic?"!":"."}`);return0;}}awaitrun(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";exportinterfaceOptionDefinition{short?:string;type?:typeofString|typeofBoolean|[typeofString]|[typeofBoolean];}exportconstOption=(def:OptionDefinition)=>Reflect.metadata(Option,def);constgetOptionMetadata=(ctor:new()=>any,prop:string)=>Reflect.getMetadata(Option,ctor.prototype,prop)asOptionDefinition|undefined;exportconstCommand=()=>Reflect.metadata(Command,{});constgetCommandMetadata=(ctor:new()=>any,prop:string)=>Reflect.getMetadata(Command,ctor.prototype,prop)as{}|undefined;exporttypeCommandFn=(opts:Record<string,OptionValue>,...args:string[])=>void|number|Promise<void|number>;exporttypeOptionValue=undefined|boolean|string|Array<boolean|string>;exportasyncfunctionrun(Program:new()=>any){constprogram=newProgram();const{positionals:[command,...args],values:opts,}=parseArgs({strict:false,options:getOptionsConfigFromMetadata(Program,program),});for(constkofObject.keys(program)){constdef=getOptionMetadata(Program,k);if(!def)continue;if(def.short&&def.shortinopts){program[k]=opts[def.short];deleteopts[def.short];}if(kinopts){program[k]=opts[k];deleteopts[k];}}constcommands:string[]=[];for(constkofObject.getOwnPropertyNames(Program.prototype)){constdef=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;}constcommandFn:Function=program[command];constminArgCount=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{constcode=awaitprogram[command]!(opts,...args);process.exitCode=code??0;}catch(error:any){process.exitCode=error.exitCode??1;throwerror;}}typeOptionsConfig=Exclude<ParseArgsConfig["options"],undefined>;functiongetOptionsConfigFromMetadata(Program:new()=>any,program:any):OptionsConfig{constconfig:OptionsConfig={};for(constkofObject.keys(program)){constdef=getOptionMetadata(Program,k);if(!def)continue;// getting the TypeScript type metadataconstdefType=def.type??Reflect.getMetadata("design:type",Program.prototype,k);letshort=def.short;lettype:"string"|"boolean"="string";letmultiple=false;if(defType===String){type="string";multiple=false;}elseif(defType===Boolean){type="boolean";multiple=false;}elseif(Array.isArray(defType)){multiple=true;if(defType[0]===String){type="string";}elseif(defType[0]===Boolean){type="boolean";}}else{thrownewError(`unable to determine option type for ${k}`);}config[k]={short,type,multiple};}returnconfig;}
stage6/main.ts
import{Command,Option,OptionValue,run}from"./framework.js";classProgram{// you might have to specify the types explicitly for emitDecoratorMetadata to work// even in cases where they are inferred correctly by the compiler@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);}constenthusiastic=opts.enthusiastic??opts.e??false;if(typeofenthusiastic!=="boolean"){console.error(`invalid type for --enthusiastic option`);return1;}console.log(`Hello ${name}${enthusiastic?"!":"."}`);return0;}}awaitrun(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:interfaceIOptions{enthusiastic:boolean;}// we'll need this:classOptions{enthusiastic:boolean;}// you can still use object literals with such typesconstopts1:Options={enthusiastic:true};constopts2:Options=JSON.parse(str);// but remember that they won't magically become instances of Options!assert(!(opts1instanceofOptions));
Let's add this final feature to our framework.
stage7/framework.ts
import"reflect-metadata";import{ParseArgsConfig,parseArgs}from"node:util";exportinterfaceOptionDefinition{short?:string;type?:typeofString|typeofBoolean|[typeofString]|[typeofBoolean];}exportconstOption=(def:OptionDefinition)=>Reflect.metadata(Option,def);constgetOptionMetadata=(ctor:new()=>any,prop:string)=>Reflect.getMetadata(Option,ctor.prototype,prop)asOptionDefinition|undefined;exportconstCommand=()=>Reflect.metadata(Command,{});constgetCommandMetadata=(ctor:new()=>any,prop:string)=>Reflect.getMetadata(Command,ctor.prototype,prop)as{}|undefined;exporttypeCommandFn=(opts:Record<string,OptionValue>,...args:string[])=>void|number|Promise<void|number>;exporttypeOptionValue=undefined|boolean|string|Array<boolean|string>;exportasyncfunctionrun(Program:new()=>any){constprogram=newProgram();const{positionals:[command],}=parseArgs({strict:false,options:getOptionsConfigFromMetadata(Program,program),});constcommands:string[]=[];for(constkofObject.getOwnPropertyNames(Program.prototype)){constdef=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;}constOptsDto=Reflect.getMetadata("design:paramtypes",Program.prototype,command)?.[0];constoptsDto=OptsDto?newOptsDto():undefined;const{positionals:[,...args],values:opts,}=parseArgs({strict:false,options:{...getOptionsConfigFromMetadata(Program,program),...getOptionsConfigFromMetadata(OptsDto,optsDto),},});Object.assign(optsDto,opts);constcommandFn:Function=program[command];constminArgCount=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{constcode=awaitprogram[command]!(optsDto,...args);process.exitCode=code??0;}catch(error:any){process.exitCode=error.exitCode??1;throwerror;}}typeOptionsConfig=Exclude<ParseArgsConfig["options"],undefined>;functiongetOptionsConfigFromMetadata(Program:new()=>any,program:any):OptionsConfig{constconfig:OptionsConfig={};for(constkofObject.keys(program)){constdef=getOptionMetadata(Program,k);if(!def)continue;constdefType=def.type??Reflect.getMetadata("design:type",Program.prototype,k);letshort=def.short;lettype:"string"|"boolean"="string";letmultiple=false;if(defType===String){type="string";multiple=false;}elseif(defType===Boolean){type="boolean";multiple=false;}elseif(Array.isArray(defType)){multiple=true;if(defType[0]===String){type="string";}elseif(defType[0]===Boolean){type="boolean";}}else{thrownewError(`unable to determine option type for ${k}`);}config[k]={short,type,multiple};}returnconfig;}functionextractOptions(Dto:new()=>any,dto:any,opts:Record<string,OptionValue>):void{for(constkofObject.keys(dto)){constdef=getOptionMetadata(Dto,k);if(!def)continue;if(def.short&&def.shortinopts){dto[k]=opts[def.short];deleteopts[def.short];}if(kinopts){dto[k]=opts[k];deleteopts[k];}}}
This is how our final API looks like in main.ts:
import{Command,Option,run}from"./framework.js";// можно даже отметить этот класс как abstractclassHelloOptions{@Option({short:"e"})enthusiastic:boolean=false;}classProgram{@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?"!":"."}`);return0;}}awaitrun(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!