В этой статье я хочу рассмотреть основные такие возможности, плюс показать, как можно получить ещё больше информации о типах при использовании TypeScript, и как добавить классам и их полям собственные метаданные при помощи декораторов. Каждую из техник я покажу на примере небольшого CLI-фреймворка, работа с которым к концу статьи будет выглядеть как на картинке:

▍ Уровень 0: никакой рефлексии
Для начала напишем код вообще без какой-либо рефлексии — по факту, просто обёртку для стандартного util.parseArgs из Node.js.
import { parseArgs } from "node:util"; export type Main = ( args: string[], opts: Record<string, OptionValue>, ) => void | number | Promise<void | number>; export type OptionValue = // опция не указана | undefined // опция-флаг, без значения | boolean // опция со значением | string // опция указана несколько раз | Array<boolean | string>; export async function run(main: Main) { const { positionals: args, values: opts } = parseArgs({ strict: false }); try { const code = await main(args, opts); process.exitCode = code ?? 0; } catch (error: any) { process.exitCode = error.exitCode ?? 1; throw error; } }
Использование такого недо-фреймворка выглядит так:
import { OptionValue, run } from "./framework.js"; await run(main); function main(args: string[], opts: Record<string, OptionValue>) { // вручную реализуем короткие имена if (opts.verbose || opts.v) { console.debug(args); console.debug(opts); } // вручную разбираем команды const [command, ...commandArgs] = args; if (!command) { console.error("no command specified"); return 1; } switch (command) { case "hello": { const [name] = commandArgs; if (!name) { console.error(`command required 1 argument, 0 given`); return 1; } // вручную проверяем типы значений опций const enthusiastic = opts.enthusiastic ?? opts.e ?? false; if (typeof enthusiastic !== "boolean") { console.error(`invalid type for --enthusiastic option`); return 1; } console.log(`Hello ${name}${enthusiastic ? "!" : "."}`); return 0; } default: console.error(`unknown command: ${command}`); return 1; } }
Как хорошо видно из этого кода, пока что фреймворк не предоставляет почти никаких способов собственно задать CLI-интерфейс. Короткие имена опций, типы их значений, диспетчеризацию команд — всё это пришлось реализовать вручную.
Это ограничение именно моего кода — самой parseArgs можно передать описание CLI-интерфейса с определениями всех опций. Но вместо того, чтобы указывать его в таком формате, я буду использовать во фреймворке рефлексию, позволя ему самому вывесить это описание.
Начну с основ.
▍ Уровень 1: основы JS-рефлексии
Эти техники настолько распространены, что применительно к JS их редко называют, собственно, рефлексией:
- Оператор typeof: определение JS-типа значения.
Выражение
typeof xможет вернуть"undefined","boolean","number","bigint","string","symbol","object","function". Важно помнить, что по историческим причинамtypeof null === "object"!Кроме того, для классов возвращается
"function"даже при условии, что просто как функцию их вызвать нельзя — только черезnew. - Оператор instanceof: определение, есть ли нужный прототип у объекта.
Если забыть про прототипное наследование и оперировать только классами, то
x instanceof Aвернёт булево значение, показывающее, является лиxэкземпляромAили его потомка. - Оператор in: проверка наличия ключа у объекта.
Выражение
"p" in xпроверяет, есть ли у объектаxключp. При этомxобязательно должен быть объектом, иначе будет выкинутаTypeError. - Функция Object.keys() и цикл for…in: перечисление ключей объекта.
Этими способами можно получить ключи только тех свойств, которые являются перечисляемыми (enumerable). Как правило, в эту категорию попадают почти все ключи, которые может понадобиться перечислить. Некоторые исключения покажу далее.
Давайте применим некоторые из них, чтобы сделать наш фреймворк чуть красивее. А именно: пусть теперь точка входа в программу будет задаваться классом, а его поля будут определять общие для всех команд опции:
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 }); // по соглашению, все поля `program` - это общие для всех команд опции for (const k of Object.keys(program)) { if (k in opts) { // полностью типизировать функции, использующие рефлексию, довольно сложно // поэтому готовьтесь — в коде будут any (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; } }
Код в main.ts теперь выглядит так:
import { OptionValue, run } from "./framework.js"; class Program { // если target слишком старый (меньше es2022), // то в нём не будет поддержки синтаксиса объявления полей // поэтому поля, которым не заданы значения, не будут присутствовать в объекте verbose: boolean | undefined = undefined; // для es2022 и новее можно писать просто: v: boolean | undefined; main(args: string[], opts: Record<string, OptionValue>) { // короткие имена всё равно приходится обрабатывать самостоятельно const verbose = this.verbose ?? this.v ?? false; if (verbose) { console.debug(this); console.debug(args); console.debug(opts); } // команды разбираем всё ещё вручную const [command, ...commandArgs] = args; if (!command) { console.error("no command specified"); return 1; } switch (command) { case "hello": { const [name] = commandArgs; if (!name) { console.error(`command required 1 argument, 0 given`); return 1; } // вручную проверяем типы значений опций const enthusiastic = opts.enthusiastic ?? opts.e ?? false; if (typeof enthusiastic !== "boolean") { console.error(`invalid type for --enthusiastic option`); return 1; } console.log(`Hello ${name}${enthusiastic ? "!" : "."}`); return 0; } default: console.error(`unknown command: ${command}`); return 1; } } } await run(Program);
Преимущества такого подхода пока что не слишком заметны: общие для команд опции мы определили, но сами команды всё равно приходится диспатчить вручную. Но это легко исправить следующим уровнем рефлексии!
▍ Уровень 2: прототипы, перечисление методов
Расширим требования для Program: все его методы будут считаться отдельными командами.
Методы объекта в JS — это просто свойства его прототипа, у которых значения — это функции. Прототип объектов класса A доступен как A.prototype.
Однако при использовании не прототипов напрямую, а классов, методы объявляются неперечисляемыми. Поэтому просто сделать Object.keys(Program.prototype) или for (k in Program.prototype) не получится. На помощь приходит Object.getOwnPropertyNames(), возвращающий все ключи данного объекта.
У этого метода есть ещё одна особенность по сравнению с Object.keys(). На неё указывает Own в имени — она возвращает ключи, принадлежащие конкретно этому объекту, не поднимаясь по цепочке прототипов — то есть не возвращает унаследованные ключи. Если они всё-таки нужны, нужно пройти по цепочке прототипов самим — примерно так:
const allKeys = []; for (let proto = A.prototype; proto; proto = Object.getPrototypeOf(proto)) { allKeys.push(...Object.getOwnPropertyNames(proto)); }
В нашем фреймворке для простоты положим, что командами могут быть только собственные методы класса Program, не унаследованные от предков. Так нам, к тому же, не придётся беспокоиться о том, что мы добавим как команды все методы общего для всех классов предка Object.
Важно также помнить, что constructor — это тоже ключ в прототипе любого класса. Его нужно будет отфильтровать.
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]; } } // по соглашению, все методы `program` - это команды // методы класса не enumerable // поэтому нам нужна getOwnPropertyNames(), а не просто 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; } }
Теперь мы наконец можем убрать из 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);
▍ Уровень 3: аргументы функций
Хорошо бы избавиться от необходимости разбирать массив args самим, и при этом заставить фреймворк проверять, что команде передано необходимое количество аргументов. Для этого немного поменяем интерфейс самих методов-команд: будем передавать аргументы не массивом, а как отдельные аргументы метода, при этом для удобства поставив opts на первое место:
// Было: hello(args: string[], opts: Record<string, OptionValue>): ... // Стало: hello(opts: Record<string, OptionValue>, name: string): ...
Теперь можно валидировать количество переданных CLI-команде аргументов на основе количества аргументов функции. Его можно получить для любой функции f при помощи свойства f.length.
Но есть одна хитрость. Свойство f.length на самом деле будет минимальным необходимым числом аргументов, которое необходимо передать функции! Оно не учитывает случаи необязательных аргументов:
// аргументы со значениями по умолчанию function f1(a, b = null) {} assert(f1.length === 1); // rest-аргументы function f2(a, ...bs) {} assert(f2.length === 1); // свойство arguments function f3(a) { doWork(arguments[2]); } assert(f3.length === 1); // плюс к этому, "лишние" аргументы просто игнорируются function f4(a) {} f4(1, 2, 3, 4, 5);
Учитывая это, реализуем валидацию минимального числа аргументов для команды:
import { parseArgs } from "node:util"; export type CommandFn = ( opts: Record<string, OptionValue>, // rest-параметр удобнее всего оставить последним ...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]; } } // по соглашению, все методы `program` - это команды // методы класса не enumerable // поэтому нам нужна getOwnPropertyNames(), а не просто 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]; // валидируем число аргументов функции // передать больше аргументов можно, меньше нет // +1 аргумент с опциями 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; } }
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);
▍ Уровень 4: декораторы и Reflect.metadata
Чтобы фреймворк умел сам понимать, какое короткое имя есть у опции, нам нужна возможность навесить на соответствующее свойство класса метаданные, в которых будет и это короткое имя, и какие-то дополнительные свойства. Кроме того, если будем явно отмечать методы-команды и свойства-опции, то сможем иметь в классе Program и посторонние свойства и методы.
Проще и красивее всего это сделать, используя декораторы.
У декораторов в JS и TS тяжёлая судьба. Пропозал несколько раз переделывали, и многие кодовые базы всё ещё завязаны на полифиллы одного из устаревших драфтов спецификации.
В компиляторе TypeScript реализованы два варианта декораторов:
- финальный (доступен без дополнительных опций начиная с TypeScript 5.0);
- на основе одного из черновиков (доступен с опцией experimentalDecorators).
Забегая вперёд, скажу, что для более продвинутых уровней рефлексии в TS нам придётся использовать именно experimentalDecorators. Но на текущем уровне мы можем совершенно абстрагироваться от этого выбора, используя библиотеку reflect-metadata:
import "reflect-metadata"; // теперь в глобальном объекте Reflect доступны новые методы class Foo { // метод Reflect.metadata() можно сразу использовать в качестве декоратора @Reflect.metadata("meta-key", "value") f() {} } // но лучше будет завернуть его в отдельную функцию const MyDecorator = (value) => // в качестве ключа удобно использовать саму эту функцию Reflect.metadata(MyDecorator, value); class Bar { @MyDecorator("value") prop: string; } // метаданные читать вот так: const value1 = Reflect.getMetadata(Foo.prototype, "meta-key", "f"); const value2 = Reflect.getMetadata(Bar.prototype, MyDecorator, "prop"); // если не передать последний аргумент, // вернутся метаданные, навешенные на сам класс, а не на его члены
Используя декораторы, reflect-metadata и обход ключей из предыдущих уровней, несложно реализовать нужную нам фичу:
import "reflect-metadata"; import { parseArgs } from "node:util"; export interface OptionDefinition { short?: string; } // декораторы с помощью Reflect.metadata // в качестве ключа метаданных удобно брать саму функцию-декоратор export const Option = (def: OptionDefinition) => Reflect.metadata(Option, def); // для лучшей типизации удобно сразу определить геттер const getOptionMetadata = (ctor: new () => any, prop: string) => Reflect.getMetadata(Option, ctor.prototype, prop) as | OptionDefinition | undefined; // даже если у декоратора на данный момент нет параметров, // его удобно все равно сделать функцией export const Command = () => Reflect.metadata(Command, {}); const getCommandMetadata = (ctor: new () => any, prop: string) => Reflect.getMetadata(Command, ctor.prototype, prop) as {} | undefined; export type CommandFn = ( opts: Record<string, OptionValue>, ...args: string[] ) => void | number | Promise<void | number>; export type OptionValue = | undefined | boolean | string | Array<boolean | string>; export async function run(Program: new () => any) { const program = new Program(); const { positionals: [command, ...args], values: opts, } = parseArgs({ strict: false }); for (const k of Object.keys(program)) { const def = getOptionMetadata(Program, k); if (!def) continue; if (def.short && def.short in opts) { program[k] = opts[def.short]; delete opts[def.short]; } if (k in opts) { program[k] = opts[k]; delete opts[k]; } } const commands: string[] = []; for (const k of Object.getOwnPropertyNames(Program.prototype)) { const def = getCommandMetadata(Program, k); if (!def) continue; commands.push(k); } if (!command) { console.error("no command specified"); console.error(`available commands: ${commands.join(", ")}`); process.exitCode = 1; return; } if (!commands.includes(command)) { console.error(`unknown command: ${command}`); console.error(`available commands: ${commands.join(", ")}`); process.exitCode = 1; return; } const commandFn: Function = program[command]; const minArgCount = commandFn.length - 1; if (args.length < minArgCount) { console.error(`too few arguments for command ${command}`); console.error(`at least ${minArgCount}, ${args.length} given`); process.exitCode = 1; return; } try { const code = await program[command]!(opts, ...args); process.exitCode = code ?? 0; } catch (error: any) { process.exitCode = error.exitCode ?? 1; throw error; } }
Теперь код в main.ts выглядит так:
import { Command, Option, OptionValue, run } from "./framework.js"; class Program { // можно задать опции короткое имя @Option({ short: "v" }) verbose = false; // теперь можно иметь поля, не являющиеся опциями version = "1.0.0"; @Command() hello(opts: Record<"e" | "enthusiastic", OptionValue>, name: string) { if (this.verbose) { console.debug(this); console.debug([name]); console.debug(opts); } // опции конкретной команды пока что все равно нужно разбирать руками const enthusiastic = opts.enthusiastic ?? opts.e ?? false; if (typeof enthusiastic !== "boolean") { console.error(`invalid type for --enthusiastic option`); return 1; } console.log(`Hello ${name}${enthusiastic ? "!" : "."}`); return 0; } } await run(Program);
▍ Уровень 5: описание типов для рантайма
Неплохо бы в декоратор @Option() добавить также тип значения опции. Но стандартного способа описать тип значения в JS другим JS-значением, к сожалению, нет. Есть, конечно, то, что возвращает typeof, но этого недостаточно для сложных типов — объектов и массивов.
К счастью, есть ряд договорённостей и умолчаний, которые часто используются библиотеками и фреймворками для такой задачи. В частности, я буду ориентироваться на соглашения, которые повсеместно используются в Nest.js:
// примитивные типы представлены их "конструктором" const number = Number; const string = String; // массивы представлены одноэлементными массивами const arrayOfNumber = [Number]; const arrayOfString = [String]; // объекты представлены, кхм, объектами const person = { name: String, age: Number, }; // и их можно использовать в любых комбинациях const dto = { people: [{ name: String, age: Number }], }; // получается синтаксис, похожий на типы в TypeScript type Dto = { people: { name: string; age: number }[]; };
В TypeScript при работе с таким рантайм-представлением типов важно не забывать, где оно, а где типы самого TypeScript. Если, к примеру, случайно объявить поле какого-то объекта как Number вместо number, то ошибка может выскочить в неожиданном месте — примитив можно присвоить к переменной, тип которой — его boxed-версия. Но не наоборот!
Давайте теперь используем такой синтаксис для типов, чтобы добавить в наш фреймворк проверку типов значений опций. Облегчит нам задачу то, что parseArgs поддерживает фактически только четыре типа: boolean | string | boolean[] | string[]:
import "reflect-metadata"; import { ParseArgsConfig, parseArgs } from "node:util"; export interface OptionDefinition { short?: string; // такой синтаксис для обозначения типов в рантайме уже стал стандартом де-факто type: typeof String | typeof Boolean | [typeof String] | [typeof Boolean]; } export const Option = (def: OptionDefinition) => Reflect.metadata(Option, def); const getOptionMetadata = (ctor: new () => any, prop: string) => Reflect.getMetadata(Option, ctor.prototype, prop) as | OptionDefinition | undefined; export const Command = () => Reflect.metadata(Command, {}); const getCommandMetadata = (ctor: new () => any, prop: string) => Reflect.getMetadata(Command, ctor.prototype, prop) as {} | undefined; export type CommandFn = ( opts: Record<string, OptionValue>, ...args: string[] ) => void | number | Promise<void | number>; export type OptionValue = | undefined | boolean | string | Array<boolean | string>; export async function run(Program: new () => any) { const program = new Program(); const { positionals: [command, ...args], values: opts, } = parseArgs({ strict: false, options: getOptionsConfigFromMetadata(Program, program), }); for (const k of Object.keys(program)) { const def = getOptionMetadata(Program, k); if (!def) continue; if (def.short && def.short in opts) { program[k] = opts[def.short]; delete opts[def.short]; } if (k in opts) { program[k] = opts[k]; delete opts[k]; } } const commands: string[] = []; for (const k of Object.getOwnPropertyNames(Program.prototype)) { const def = getCommandMetadata(Program, k); if (!def) continue; commands.push(k); } if (!command) { console.error("no command specified"); console.error(`available commands: ${commands.join(", ")}`); process.exitCode = 1; return; } if (!commands.includes(command)) { console.error(`unknown command: ${command}`); console.error(`available commands: ${commands.join(", ")}`); process.exitCode = 1; return; } const commandFn: Function = program[command]; const minArgCount = commandFn.length - 1; if (args.length < minArgCount) { console.error(`too few arguments for command ${command}`); console.error(`at least ${minArgCount}, ${args.length} given`); process.exitCode = 1; return; } try { const code = await program[command]!(opts, ...args); process.exitCode = code ?? 0; } catch (error: any) { process.exitCode = error.exitCode ?? 1; throw error; } } // к сожалению, этот тип не экспортируется в более удобоваримом виде type OptionsConfig = Exclude<ParseArgsConfig["options"], undefined>; function getOptionsConfigFromMetadata( Program: new () => any, program: any, ): OptionsConfig { const config: OptionsConfig = {}; for (const k of Object.keys(program)) { const def = getOptionMetadata(Program, k); if (!def) continue; let short = def.short; let type: "string" | "boolean" = "string"; let multiple = false; if (def.type === String) { type = "string"; multiple = false; } else if (def.type === Boolean) { type = "boolean"; multiple = false; } else if (Array.isArray(def.type)) { multiple = true; if (def.type[0] === String) { type = "string"; } else if (def.type[0] === Boolean) { type = "boolean"; } } config[k] = { short, type, multiple }; } return config; }
import { Command, Option, OptionValue, run } from "./framework.js"; class Program { @Option({ type: Boolean, short: "v" }) verbose = false; version = "1.0.0"; @Command() hello(opts: Record<"e" | "enthusiastic", OptionValue>, name: string) { if (this.verbose) { console.debug(this); console.debug([name]); console.debug(opts); } const enthusiastic = opts.enthusiastic ?? opts.e ?? false; if (typeof enthusiastic !== "boolean") { console.error(`invalid type for --enthusiastic option`); return 1; } console.log(`Hello ${name}${enthusiastic ? "!" : "."}`); return 0; } } await run(Program);
▍ Уровень 6: спрашиваем типы у самого TypeScript
При использовании TypeScript есть возможность не изобретать синтаксис для описания типов в рантайме, фактически дублируя их описания в TypeScript, а сказать компилятору сохранить информацию о типах в метаданные класса. Для этого потребуется:
- опция компилятора emitDecoratorMetadata — для сохранения метаданных;
- опция компилятора experimentalDecorators — для работы первой опции;
- библиотека reflect-metadata — для чтения сохранённых метаданных.
Нужны именно «старые» декораторы, а не новые стандартные. На данный момент, emitDecoratorMetadata требует experimentalDecorators.
Метаданные сохраняются только для членов классов, причём только для тех, на которых уже висит хотя бы один декоратор. Конкретный интерфейс не описан в документации компилятора, но его можно понять, если поэкспериментировать с тем, во что компилируются различные выражения.
Самое важное, что о нём нужно знать заранее — он далеко не такой подробный, как хотелось бы. Фактически для каждого типа сохраняется только его «конструктор», если это понятие к нему вообще применимо. То есть: для класса Foo будет сохранён Foo, для number будет сохранён Number, для number[] — Array, а для типа-литерала { name: string } — просто Object. Обиднее всего за массивы: объекты хотя бы можно представить классами, но для массивов всё равно придётся оставить способ явно указывать тип их элементов.
Из-за этого, а также ради использования без TypeScript, этот способ получения информации о типах нельзя оставить как единственный. Тем не менее, реализовать его несложно, и некоторой тавтологии он позволяет избежать.
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; // получаем информацию из типов TypeScript 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; }
import { Command, Option, OptionValue, run } from "./framework.js"; class Program { // для правильной работы 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); } 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);
▍ Уровень 7: типы аргументов методов, DTO-классы
Тем же способом — с помощью emitDecoratorMetadata — можно узнать и типы аргументов функции. Воспользуемся этим, чтобы наконец-то позволить фреймворку самому выводить типы опций для отдельных команд.
Подвох, конечно, в том, что, как я писал выше, для типов-объектов метаданные сохранятся, только если этот тип — класс. Для того, чтобы обойти это ограничение, нужно объявлять типы-объекты именно как class, а не как interface или тип-литерал.
// вместо этого: interface IOptions { enthusiastic: boolean; } // объявлять так: class Options { enthusiastic: boolean; } // можно продолжать использовать привычный синтаксис, // система типов такое допускает const opts1: Options = { enthusiastic: true }; const opts2: Options = JSON.parse(str); // главное не забывать, что эти объекты не станут волшебным образом // экземплярами класса Options assert(!(opts1 instanceof Options));
Добавим этот последний штрих к нашему фреймворку, чтобы клиентскому коду уже совсем не нужно было разбирать опции руками:
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; // получаем информацию из типов TypeScript 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]; } } }
Итоговый клиентский код из main.ts:
import { Command, Option, run } from "./framework.js"; // можно даже отметить этот класс как abstract class HelloOptions { @Option({ short: "e" }) enthusiastic: boolean = false; } class Program { @Option({ short: "v" }) verbose: boolean = false; version = "1.0.0"; @Command() hello({ enthusiastic }: HelloOptions, name: string) { if (this.verbose) { console.debug(this); console.debug([name]); console.debug({ enthusiastic }); } console.log(`Hello ${name}${enthusiastic ? "!" : "."}`); return 0; } } await run(Program);
▍ Что в итоге?
Как видно из финального кода, нам удалось спрятать внутрь нашего маленького фреймворка весь код, связанный с обработкой аргументов командной строки. Клиенту достаточно организовать свой код в классы, придерживаясь некоторых соглашений, а фреймворк уже сделает всё сам.
Схожие механизмы рефлексии довольно широко применяются во многих TypeScript-фреймворках. Основным вдохновением для этой статьи был, конечно, Nest.js. Но я считаю, что независимо от выбора фреймворка знание этих механизмов может помочь проектировать более логичные, лаконичные и удобные API.
Telegram-канал с розыгрышами призов, новостями IT и постами о ретроиграх ?️
ссылка на оригинал статьи https://habr.com/ru/articles/754764/

Добавить комментарий