В этой статье я хочу рассмотреть основные такие возможности, плюс показать, как можно получить ещё больше информации о типах при использовании 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.
Автор: Илья Поздняков