Как и в любом достаточно динамическом языке, в JavaScript из коробки есть способы разобрать в рантайме структуру его значений — определить типы, ключи объектов, получить конструкторы и прототипы.
В этой статье я хочу разобрать основные такие возможности, плюс показать, как можно получить еще больше информации о типах при использовании TypeScript, и как добавить классам и их полям собственные метаданные при помощи декораторов. Каждую из техник я покажу на примере небольшого CLI-фреймворка, работа с которым к концу статьи будет выглядеть как на картинке:
Весь мой обзор рефлексии — и всю работу над фреймворком — я разделю на несколько уровней.
Уровень 0: никакой рефлексии
Для начала напишем код вообще без какой-либо рефлексии — по факту, просто обертку для стандартного util.parseArgs из Node.js.
stage0/framework.ts
import{parseArgs}from"node:util";exporttypeMain=(args:string[],opts:Record<string,OptionValue>,)=>void|number|Promise<void|number>;exporttypeOptionValue=// опция не указана|undefined// опция-флаг, без значения|boolean// опция со значением|string// опция указана несколько раз|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;}}
Использование такого недо-фреймворка выглядит так:
Как хорошо видно из этого кода, пока что фреймворк не предоставляет почти никаких способов собственно задать CLI-интерфейс. Короткие имена опций, типы их значений, диспетчеризацию команд — все это пришлось реализовать вручную.
Это ограничение именно моего кода — самой parseArgs можно передать описание CLI-интерфейса с определениями всех опций. Но вместо того, чтобы указывать его в таком формате, я буду использовать во фреймворке рефлексию, позволя ему самому вывесит это описание.
Начну с основ.
Уровень 1: основы JS-рефлексии
Эти техники настолько распространены, что применительно к JS их редко называют, собственно, рефлексией:
Выражение typeof x может вернуть "undefined", "boolean", "number", "bigint", "string", "object", "function". Важно помнить, что по историческим причинам typeof null === "object"!
Кроме того, для классов возвращается "function", даже при условии, что просто как функцию их вызвать нельзя — только через new.
Если забыть про прототипное наследование и оперировать только классами, то x instanceof A вернет булево значение, показывающее, является ли x экземпляром A или его потомка.
Этими способами можно перечислить ключи только тех свойств, которые являются перечисляемыми (enumerable). Как правило, в эту категорию попадают почти все ключи, которые может понадобиться перечислить. Некоторые исключения покажу далее.
Давайте применим некоторые из них, чтобы сделать наш фреймворк чуть красивее. А именно: пусть теперь точка входа в программу будет задаваться классом, а его поля будут определять общие для всех команд опции:
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});// по соглашению, все поля `program` - это общие для всех команд опцииfor(constkofObject.keys(program)){if(kinopts){// полностью типизировать функции, использующие рефлексию, довольно сложно// поэтому готовьтесь — в коде будут any(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;}}
Код в main.ts теперь выглядит так:
stage1/main.ts
import{OptionValue,run}from"./framework.js";classProgram{// если target слишком старый (меньше es2022),// то в нем не будет поддержки синтаксиса объявления полей// поэтому поля, которым не заданы значения, не будут присутствовать в объектеverbose:boolean|undefined=undefined;// для es2022 и новее можно писать просто:v:boolean|undefined;main(args:string[],opts:Record<string,OptionValue>){// короткие имена все равно приходится обрабатывать самостоятельноconstverbose=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");return1;}switch(command){case"hello":{const[name]=commandArgs;if(!name){console.error(`command required 1 argument, 0 given`);return1;}// вручную проверяем типы значений опцийconstenthusiastic=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);
Преимущества такого подхода пока что не слишком заметны: общие для команд опции мы определили, но сами команды все равно приходится диспатчить вручную. Но это легко исправить следующим уровнем рефлексии!
Уровень 2: прототипы, перечисление методов
Расширим требования для Program: все его методы будут считаться отдельными командами.
Методы объекта в JS — это просто свойства его прототипа, у которых значения — это функции. Прототип объектов класса A доступен как A.prototype.
Однако при использовании не прототипов напрямую, а классов, методы объявляются не-перечисляемыми. Поэтому просто сделать Object.keys(Program.prototype) или for (k in Program.prototype) не получится. На помощь приходит Object.getOwnPropertyNames(), возвращающий все ключи данного объекта.
У этого метода есть еще одна особенность по сравнению с Object.keys(). На нее указывает Own в имени — она возвращает ключи, принадлежащие конкретно этому объекту, не поднимаясь по цепочке прототипов — то есть, не возвращает унаследованные ключи. Если они все-таки нужны, нужно пройти по цепочке прототипов самим — примерно так:
В нашем фреймворке для простоты положим, что командами могут быть только собственные методы класса Program, не унаследованные от предков. Так нам, к тому же, не придется беспокоится о том, что мы добавим как команды все методы общего для всех классов предка Object.
Важно также помнить, что constructor — это тоже ключ в прототипе любого класса. Его нужно будет отфильтровать.
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];}}// по соглашению, все методы `program` - это команды// методы класса не enumerable// поэтому нам нужна getOwnPropertyNames(), а не просто 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;}}
Теперь мы, наконец, можем убрать из 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);
Уровень 3: аргументы функций
Хорошо бы избавиться от необходимости разбирать массив args самим, и при этом заставить фреймворк сам проверять, что команде передано необходимое количество аргументов. Для этого немного поменяем интерфейс самих методов-команд: будем передавать аргументы не массивом, а как отдельные аргументы метода, при этом для удобства поставив opts на первое место:
Теперь можно валидировать количество переданных CLI-команде аргументов на основе количества аргументов функции. Его можно получить для любой функции f при помощи свойства f.length.
Но есть одна хитрость. Свойство f.length на самом деле будет минимальным необходимым числом аргументов, которое необходимо передать функции! Оно не учитывает случаи необязательных аргументов:
// аргументы со значениями по умолчаниюfunctionf1(a,b=null){}assert(f1.length===1);// rest-аргументыfunctionf2(a,...bs){}assert(f2.length===1);// свойство argumentsfunctionf3(a){doWork(arguments[2]);}assert(f3.length===1);// плюс к этому, "лишние" аргументы просто игнорируютсяfunctionf4(a){}f4(1,2,3,4,5);
Учитывая это, реализуем валидацию минимального числа аргументов для команды:
stage3/framework.ts
import{parseArgs}from"node:util";exporttypeCommandFn=(opts:Record<string,OptionValue>,// rest-параметр удобнее всего оставить последним...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];}}// по соглашению, все методы `program` - это команды// методы класса не enumerable// поэтому нам нужна getOwnPropertyNames(), а не просто 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];// валидируем число аргументов функции// передать больше аргументов можно, меньше нет// +1 аргумент с опциями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;}}
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);
Уровень 4: декораторы и Reflect.metadata
Чтобы фреймворк умел сам понимать, какое короткое имя есть у опции, нам нужна возможность навесить на соответствующее свойство класса метаданные, в которых будет и это короткое имя, и какие-то дополнительные свойства. Кроме того, если будем явно отмечать методы-команды и свойства-опции, то сможем иметь в классе Program и посторонние свойства и методы.
Проще и красивее всего это сделать, используя декораторы.
У декораторов в JS и TS тяжелая судьба. Пропозал несколько раз переделывали, и многие кодовые базы все еще завязаны на полифиллы одного из устаревших драфтов спецификации.
В компиляторе TypeScript реализованы два варианта декораторов:
Забегая вперед, скажу, что для более продвинутых уровней рефлексии в TS нам придется использовать именно experimentalDecorators. Но на текущем уровне мы можем совершенно абстрагироваться от этого выбора, используя библиотеку reflect-metadata:
import"reflect-metadata";// теперь в глобальном объекте Reflect доступны новые методыclassFoo{// метод Reflect.metadata() можно сразу использовать в качестве декоратора@Reflect.metadata("meta-key","value")f(){}}// но лучше будет завернуть его в отдельную функциюconstMyDecorator=(value)=>// в качестве ключа удобно использовать саму эту функцию
Reflect.metadata(MyDecorator,value);classBar{@MyDecorator("value")prop:string;}// метаданные читать вот так:constvalue1=Reflect.getMetadata(Foo.prototype,"meta-key","f");constvalue2=Reflect.getMetadata(Bar.prototype,MyDecorator,"prop");// если не передать последний аргумент,// вернутся метаданные, навешенные на сам класс, а не на его члены
Используя декораторы, reflect-metadata и обход ключей из предыдущих уровней, несложно реализовать нужную фичу:
stage4/framework.ts
import"reflect-metadata";import{parseArgs}from"node:util";exportinterfaceOptionDefinition{short?:string;}// декораторы с помощью Reflect.metadata// в качестве ключа метаданных удобно брать саму функцию-декораторexportconstOption=(def:OptionDefinition)=>Reflect.metadata(Option,def);// для лучшей типизации удобно сразу определить геттерconstgetOptionMetadata=(ctor:new()=>any,prop:string)=>Reflect.getMetadata(Option,ctor.prototype,prop)as|OptionDefinition|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});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;}}
Теперь код в main.ts выглядит так:
stage4/main.ts
import{Command,Option,OptionValue,run}from"./framework.js";classProgram{// можно задать опции короткое имя@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);}// опции конкретной команды пока что все равно нужно разбирать руками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);
Уровень 5: описание типов для рантайма
Неплохо бы в декоратор @Option() добавить также тип значения опции. Но стандартного способа описать тип значения в JS другим JS-значением, к сожалению, нет. Есть, конечно, то, что возвращает typeof, но этого недостаточно для сложных типов — объектов и массивов.
К счастью, есть ряд договоренностей и умолчаний, которые часто используются библиотеками и фреймворками для такой задачи. В частности, я буду ориентироваться на соглашения, которые повсеместноиспользуютсяв Nest.js:
// примитивные типы представлены их "конструктором"constnumber=Number;conststring=String;// массивы представлены одноэлементными массивамиconstarrayOfNumber=[Number];constarrayOfString=[String];// объекты представлены, кхм, объектамиconstperson={name:String,age:Number,};// и их можно использовать в любых комбинацияхconstdto={people:[{name:String,age:Number}],};// получается синтаксис, похожий на типы в TypeScripttypeDto={people:{name:string;age:number}[];};
В TypeScript при работе с таким рантайм-представлением типов важно не забывать, где оно, а где типы самого TypeScript. Если, к примеру, случайно объявить поле какого-то объекта как Number вместо number, то ошибка может выскочить в неожиданном месте — примитив можно присвоить к переменной, тип которой — его boxed-версия. Но не наоборот!
Давайте теперь используем такой синтаксис для типов, чтобы добавить в наш фреймворк проверку типов значений опций. Облегчит нам задачу то, что parseArgs поддерживает, фактически, только четыре типа: boolean | string | boolean[] | 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)as|OptionDefinition|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;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);
Уровень 6: спрашиваем типы у самого TypeScript
При использовании TypeScript, есть возможность не изобретать синтаксис для описания типов в рантайме — фактически, дублируя их описания в TypeScript — а сказать компилятору сохранить информацию о типах в метаданные класса. Для этого потребуется:
Нужны именно "старые" декораторы, а не новые стандартные. На данным момент, emitDecoratorMetadataтребуетexperimentalDecorators.
Метаданные сохраняются только для членов классов, причем только для тех, на которых уже висит хотя бы один декоратор. Конкретный интерфейс не описан в документации компилятора, но его можно понять, если поэкспериментировать с тем, во что компилируются различные выражения.
Самое важное, что о нем нужно знать заранее — он далеко не такой подробный, как хотелось бы. Фактически, для каждого типа сохраняется только его "конструктор", если это понятие к нему вообще применимо. То есть, для класса Foo будет сохранен Foo, для number будет сохранен Number, для number[] — Array, а для типа-литерала { name: string } — просто Object. Обиднее всего за массивы: объекты хотя бы можно представить классами, но для массивов все равно придется оставить способ явно указывать тип их элементов.
Из-за этого, а также ради использования без TypeScript, этот способ получения информации о типах нельзя оставить как единственный. Тем не менее, реализовать его несложно, и некоторой тавтологии он позволяет избежать.
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)as|OptionDefinition|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;// получаем информацию из типов TypeScriptconstdefType=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{// для правильной работы emitDecoratorMetadata// может понадобиться явно указать типы, даже если их можно вывести@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);
Уровень 7: типы аргументов методов, DTO-классы
Тем же способом — с помощью emitDecoratorMetadata — можно узнать и типы аргументов функции. Воспользуемся этим, чтобы наконец-то позволить фреймворку самому выводить типы опций для отдельных команд.
Подвох, конечно, в том, что — как я писал выше — для типов-объектов метаданные сохранятся, только если этот тип — класс. Для того, чтобы обойти это ограничение, нужно объявлять типы-объекты именно как class, а не как interface или тип-литерал.
// вместо этого:interfaceIOptions{enthusiastic:boolean;}// объявлять так:classOptions{enthusiastic:boolean;}// можно продолжать использовать привычный синтаксис,// система типов такое допускаетconstopts1:Options={enthusiastic:true};constopts2:Options=JSON.parse(str);// главное не забывать, что эти объекты не станут волшебным образом// экземплярами класса Optionsassert(!(opts1instanceofOptions));
Добавим этот последний штрих к нашему фреймворку, чтобы клиентскому коду уже совсем не нужно было разбирать опции руками:
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)as|OptionDefinition|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;// получаем информацию из типов TypeScriptconstdefType=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];}}}
Итоговый клиентский код из 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()
Как видно из финального кода, нам удалось упрятать внутрь нашего маленького фреймворка весь код, связанный с обработкой аргументов командной строки. Клиенту достаточно организовать свой код в классы, придерживаясь некоторых соглашений, а фреймворк уже сделает все сам.
Схожие механизмы рефлексии довольно широко применяются во многих TypeScript-фреймворках. Основным вдохновением для этой статьи был, конечно, Nest.js. Но я считаю, что — независимо от выбора фреймворка — знание этих механизмов может помочь проектировать более логичные, лаконичные и удобные API.