Как и в любом достаточно динамическом языке, в JavaScript из коробки есть способы разобрать в рантайме структуру его значений — определить типы, ключи объектов, получить конструкторы и прототипы.
В этой статье я хочу рассмотреть основные такие возможности, плюс показать, как можно получить ещё больше информации о типах при использовании TypeScript, и как добавить классам и их полям собственные метаданные при помощи декораторов. Каждую из техник я покажу на примере небольшого CLI-фреймворка, работа с которым к концу статьи будет выглядеть как на картинке:
Для начала напишем код вообще без какой-либо рефлексии — по факту, просто обёртку для стандартного util.parseArgs из Node.js.
Использование такого недо-фреймворка выглядит так:
Как хорошо видно из этого кода, пока что фреймворк не предоставляет почти никаких способов собственно задать CLI-интерфейс. Короткие имена опций, типы их значений, диспетчеризацию команд — всё это пришлось реализовать вручную.
Это ограничение именно моего кода — самой
Начну с основ.
Эти техники настолько распространены, что применительно к JS их редко называют, собственно, рефлексией:
Давайте применим некоторые из них, чтобы сделать наш фреймворк чуть красивее. А именно: пусть теперь точка входа в программу будет задаваться классом, а его поля будут определять общие для всех команд опции:
Код в
Преимущества такого подхода пока что не слишком заметны: общие для команд опции мы определили, но сами команды всё равно приходится диспатчить вручную. Но это легко исправить следующим уровнем рефлексии!
Расширим требования для
Методы объекта в JS — это просто свойства его прототипа, у которых значения — это функции. Прототип объектов класса
Однако при использовании не прототипов напрямую, а классов, методы объявляются неперечисляемыми. Поэтому просто сделать
У этого метода есть ещё одна особенность по сравнению с
В нашем фреймворке для простоты положим, что командами могут быть только собственные методы класса
Важно также помнить, что
Теперь мы наконец можем убрать из
Хорошо бы избавиться от необходимости разбирать массив
Теперь можно валидировать количество переданных CLI-команде аргументов на основе количества аргументов функции. Его можно получить для любой функции
Но есть одна хитрость. Свойство
Учитывая это, реализуем валидацию минимального числа аргументов для команды:
Чтобы фреймворк умел сам понимать, какое короткое имя есть у опции, нам нужна возможность навесить на соответствующее свойство класса метаданные, в которых будет и это короткое имя, и какие-то дополнительные свойства. Кроме того, если будем явно отмечать методы-команды и свойства-опции, то сможем иметь в классе
Проще и красивее всего это сделать, используя декораторы.
У декораторов в JS и TS тяжёлая судьба. Пропозал несколько раз переделывали, и многие кодовые базы всё ещё завязаны на полифиллы одного из устаревших драфтов спецификации.
В компиляторе TypeScript реализованы два варианта декораторов:
Забегая вперёд, скажу, что для более продвинутых уровней рефлексии в TS нам придётся использовать именно
Используя декораторы,
Теперь код в
Неплохо бы в декоратор
К счастью, есть ряд договорённостей и умолчаний, которые часто используются библиотеками и фреймворками для такой задачи. В частности, я буду ориентироваться на соглашения, которые повсеместно используются в Nest.js:
В TypeScript при работе с таким рантайм-представлением типов важно не забывать, где оно, а где типы самого TypeScript. Если, к примеру, случайно объявить поле какого-то объекта как
Давайте теперь используем такой синтаксис для типов, чтобы добавить в наш фреймворк проверку типов значений опций. Облегчит нам задачу то, что
При использовании TypeScript есть возможность не изобретать синтаксис для описания типов в рантайме, фактически дублируя их описания в TypeScript, а сказать компилятору сохранить информацию о типах в метаданные класса. Для этого потребуется:
Нужны именно «старые» декораторы, а не новые стандартные. На данный момент,
Метаданные сохраняются только для членов классов, причём только для тех, на которых уже висит хотя бы один декоратор. Конкретный интерфейс не описан в документации компилятора, но его можно понять, если поэкспериментировать с тем, во что компилируются различные выражения.
Самое важное, что о нём нужно знать заранее — он далеко не такой подробный, как хотелось бы. Фактически для каждого типа сохраняется только его «конструктор», если это понятие к нему вообще применимо. То есть: для класса
Из-за этого, а также ради использования без TypeScript, этот способ получения информации о типах нельзя оставить как единственный. Тем не менее, реализовать его несложно, и некоторой тавтологии он позволяет избежать.
Тем же способом — с помощью
Подвох, конечно, в том, что, как я писал выше, для типов-объектов метаданные сохранятся, только если этот тип — класс. Для того, чтобы обойти это ограничение, нужно объявлять типы-объекты именно как
Добавим этот последний штрих к нашему фреймворку, чтобы клиентскому коду уже совсем не нужно было разбирать опции руками:
Итоговый клиентский код из
Как видно из финального кода, нам удалось спрятать внутрь нашего маленького фреймворка весь код, связанный с обработкой аргументов командной строки. Клиенту достаточно организовать свой код в классы, придерживаясь некоторых соглашений, а фреймворк уже сделает всё сам.
Схожие механизмы рефлексии довольно широко применяются во многих TypeScript-фреймворках. Основным вдохновением для этой статьи был, конечно, Nest.js. Но я считаю, что независимо от выбора фреймворка знание этих механизмов может помочь проектировать более логичные, лаконичные и удобные API.
В этой статье я хочу рассмотреть основные такие возможности, плюс показать, как можно получить ещё больше информации о типах при использовании TypeScript, и как добавить классам и их полям собственные метаданные при помощи декораторов. Каждую из техник я покажу на примере небольшого CLI-фреймворка, работа с которым к концу статьи будет выглядеть как на картинке:
▍ Уровень 0: никакой рефлексии
Для начала напишем код вообще без какой-либо рефлексии — по факту, просто обёртку для стандартного util.parseArgs из Node.js.
stage0/framework.ts
import { parseArgs } from "node:util";
export type Main = (
args: string[],
opts: Record<string, OptionValue>,
) => void | number | Promise<void | number>;
export type OptionValue =
// опция не указана
| 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;
}
}
Использование такого недо-фреймворка выглядит так:
stage0/main.ts
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). Как правило, в эту категорию попадают почти все ключи, которые может понадобиться перечислить. Некоторые исключения покажу далее.
Давайте применим некоторые из них, чтобы сделать наш фреймворк чуть красивее. А именно: пусть теперь точка входа в программу будет задаваться классом, а его поля будут определять общие для всех команд опции:
stage1/framework.ts
import { parseArgs } from "node:util";
export interface Program {
main(
args: string[],
opts: Record<string, OptionValue>,
): void | number | Promise<void | number>;
}
export type OptionValue =
| undefined
| boolean
| string
| Array<boolean | string>;
export async function run(Program: new () => Program) {
const program = new Program();
const { positionals: args, values: opts } = parseArgs({ strict: false });
// по соглашению, все поля `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
теперь выглядит так:
stage1/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
— это тоже ключ в прототипе любого класса. Его нужно будет отфильтровать.
stage2/framework.ts
import { parseArgs } from "node:util";
export type CommandFn = (
args: string[],
opts: Record<string, OptionValue>,
) => void | number | Promise<void | number>;
export type OptionValue =
| undefined
| boolean
| string
| Array<boolean | string>;
export async function run(Program: new () => any) {
const program = new Program();
const {
positionals: [command, ...args],
values: opts,
} = parseArgs({ strict: false });
const sharedOpts = Object.keys(program);
for (const k of sharedOpts) {
if (k in opts) {
program[k] = opts[k];
delete opts[k];
}
}
// по соглашению, все методы `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
код диспетчеризации команд:
stage2/main.ts
import { OptionValue, run } from "./framework.js";
class Program {
verbose: boolean | undefined;
v: boolean | undefined;
hello(args: string[], opts: Record<"e" | "enthusiastic", OptionValue>) {
const verbose = this.verbose ?? this.v ?? false;
if (verbose) {
console.debug(this);
console.debug(args);
console.debug(opts);
}
const enthusiastic = opts.enthusiastic ?? opts.e ?? false;
if (typeof enthusiastic !== "boolean") {
console.error(`invalid type for --enthusiastic option`);
return 1;
}
const [name] = args;
if (!name) {
console.error(`command required 1 argument, 0 given`);
return 1;
}
console.log(`Hello ${name}${enthusiastic ? "!" : "."}`);
return 0;
}
}
await run(Program);
▍ Уровень 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);
Учитывая это, реализуем валидацию минимального числа аргументов для команды:
stage3/framework.ts
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;
}
}
stage3/main.js
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
и обход ключей из предыдущих уровней, несложно реализовать нужную нам фичу:
stage4/framework.ts
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
выглядит так:
stage4/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[]
:
stage5/framework.ts
import "reflect-metadata";
import { ParseArgsConfig, parseArgs } from "node:util";
export interface OptionDefinition {
short?: string;
// такой синтаксис для обозначения типов в рантайме уже стал стандартом де-факто
type: typeof String | typeof Boolean | [typeof String] | [typeof Boolean];
}
export const Option = (def: OptionDefinition) => Reflect.metadata(Option, def);
const getOptionMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Option, ctor.prototype, prop) as
| OptionDefinition
| undefined;
export const Command = () => Reflect.metadata(Command, {});
const getCommandMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Command, ctor.prototype, prop) as {} | undefined;
export type CommandFn = (
opts: Record<string, OptionValue>,
...args: string[]
) => void | number | Promise<void | number>;
export type OptionValue =
| undefined
| boolean
| string
| Array<boolean | string>;
export async function run(Program: new () => any) {
const program = new Program();
const {
positionals: [command, ...args],
values: opts,
} = parseArgs({
strict: false,
options: getOptionsConfigFromMetadata(Program, program),
});
for (const k of Object.keys(program)) {
const def = getOptionMetadata(Program, k);
if (!def) continue;
if (def.short && def.short in opts) {
program[k] = opts[def.short];
delete opts[def.short];
}
if (k in opts) {
program[k] = opts[k];
delete opts[k];
}
}
const commands: string[] = [];
for (const k of Object.getOwnPropertyNames(Program.prototype)) {
const def = getCommandMetadata(Program, k);
if (!def) continue;
commands.push(k);
}
if (!command) {
console.error("no command specified");
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
if (!commands.includes(command)) {
console.error(`unknown command: ${command}`);
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
const commandFn: Function = program[command];
const minArgCount = commandFn.length - 1;
if (args.length < minArgCount) {
console.error(`too few arguments for command ${command}`);
console.error(`at least ${minArgCount}, ${args.length} given`);
process.exitCode = 1;
return;
}
try {
const code = await program[command]!(opts, ...args);
process.exitCode = code ?? 0;
} catch (error: any) {
process.exitCode = error.exitCode ?? 1;
throw error;
}
}
// к сожалению, этот тип не экспортируется в более удобоваримом виде
type OptionsConfig = Exclude<ParseArgsConfig["options"], undefined>;
function getOptionsConfigFromMetadata(
Program: new () => any,
program: any,
): OptionsConfig {
const config: OptionsConfig = {};
for (const k of Object.keys(program)) {
const def = getOptionMetadata(Program, k);
if (!def) continue;
let short = def.short;
let type: "string" | "boolean" = "string";
let multiple = false;
if (def.type === String) {
type = "string";
multiple = false;
} else if (def.type === Boolean) {
type = "boolean";
multiple = false;
} else if (Array.isArray(def.type)) {
multiple = true;
if (def.type[0] === String) {
type = "string";
} else if (def.type[0] === Boolean) {
type = "boolean";
}
}
config[k] = { short, type, multiple };
}
return config;
}
stage5/main.ts
import { Command, Option, OptionValue, run } from "./framework.js";
class Program {
@Option({ 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, этот способ получения информации о типах нельзя оставить как единственный. Тем не менее, реализовать его несложно, и некоторой тавтологии он позволяет избежать.
stage6/framework.ts
import "reflect-metadata";
import { ParseArgsConfig, parseArgs } from "node:util";
export interface OptionDefinition {
short?: string;
type?: typeof String | typeof Boolean | [typeof String] | [typeof Boolean];
}
export const Option = (def: OptionDefinition) => Reflect.metadata(Option, def);
const getOptionMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Option, ctor.prototype, prop) as
| OptionDefinition
| undefined;
export const Command = () => Reflect.metadata(Command, {});
const getCommandMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Command, ctor.prototype, prop) as {} | undefined;
export type CommandFn = (
opts: Record<string, OptionValue>,
...args: string[]
) => void | number | Promise<void | number>;
export type OptionValue =
| undefined
| boolean
| string
| Array<boolean | string>;
export async function run(Program: new () => any) {
const program = new Program();
const {
positionals: [command, ...args],
values: opts,
} = parseArgs({
strict: false,
options: getOptionsConfigFromMetadata(Program, program),
});
for (const k of Object.keys(program)) {
const def = getOptionMetadata(Program, k);
if (!def) continue;
if (def.short && def.short in opts) {
program[k] = opts[def.short];
delete opts[def.short];
}
if (k in opts) {
program[k] = opts[k];
delete opts[k];
}
}
const commands: string[] = [];
for (const k of Object.getOwnPropertyNames(Program.prototype)) {
const def = getCommandMetadata(Program, k);
if (!def) continue;
commands.push(k);
}
if (!command) {
console.error("no command specified");
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
if (!commands.includes(command)) {
console.error(`unknown command: ${command}`);
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
const commandFn: Function = program[command];
const minArgCount = commandFn.length - 1;
if (args.length < minArgCount) {
console.error(`too few arguments for command ${command}`);
console.error(`at least ${minArgCount}, ${args.length} given`);
process.exitCode = 1;
return;
}
try {
const code = await program[command]!(opts, ...args);
process.exitCode = code ?? 0;
} catch (error: any) {
process.exitCode = error.exitCode ?? 1;
throw error;
}
}
type OptionsConfig = Exclude<ParseArgsConfig["options"], undefined>;
function getOptionsConfigFromMetadata(
Program: new () => any,
program: any,
): OptionsConfig {
const config: OptionsConfig = {};
for (const k of Object.keys(program)) {
const def = getOptionMetadata(Program, k);
if (!def) continue;
// получаем информацию из типов 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;
}
stage6/main.ts
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));
Добавим этот последний штрих к нашему фреймворку, чтобы клиентскому коду уже совсем не нужно было разбирать опции руками:
stage7/framework.ts
import "reflect-metadata";
import { ParseArgsConfig, parseArgs } from "node:util";
export interface OptionDefinition {
short?: string;
type?: typeof String | typeof Boolean | [typeof String] | [typeof Boolean];
}
export const Option = (def: OptionDefinition) => Reflect.metadata(Option, def);
const getOptionMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Option, ctor.prototype, prop) as
| OptionDefinition
| undefined;
export const Command = () => Reflect.metadata(Command, {});
const getCommandMetadata = (ctor: new () => any, prop: string) =>
Reflect.getMetadata(Command, ctor.prototype, prop) as {} | undefined;
export type CommandFn = (
opts: Record<string, OptionValue>,
...args: string[]
) => void | number | Promise<void | number>;
export type OptionValue =
| undefined
| boolean
| string
| Array<boolean | string>;
export async function run(Program: new () => any) {
const program = new Program();
const {
positionals: [command],
} = parseArgs({
strict: false,
options: getOptionsConfigFromMetadata(Program, program),
});
const commands: string[] = [];
for (const k of Object.getOwnPropertyNames(Program.prototype)) {
const def = getCommandMetadata(Program, k);
if (!def) continue;
commands.push(k);
}
if (!command) {
console.error("no command specified");
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
if (!commands.includes(command)) {
console.error(`unknown command: ${command}`);
console.error(`available commands: ${commands.join(", ")}`);
process.exitCode = 1;
return;
}
const OptsDto = Reflect.getMetadata(
"design:paramtypes",
Program.prototype,
command,
)?.[0];
const optsDto = OptsDto ? new OptsDto() : undefined;
const {
positionals: [, ...args],
values: opts,
} = parseArgs({
strict: false,
options: {
...getOptionsConfigFromMetadata(Program, program),
...getOptionsConfigFromMetadata(OptsDto, optsDto),
},
});
Object.assign(optsDto, opts);
const commandFn: Function = program[command];
const minArgCount = commandFn.length - 1;
if (args.length < minArgCount) {
console.error(`too few arguments for command ${command}`);
console.error(`at least ${minArgCount}, ${args.length} given`);
process.exitCode = 1;
return;
}
try {
const code = await program[command]!(optsDto, ...args);
process.exitCode = code ?? 0;
} catch (error: any) {
process.exitCode = error.exitCode ?? 1;
throw error;
}
}
type OptionsConfig = Exclude<ParseArgsConfig["options"], undefined>;
function getOptionsConfigFromMetadata(
Program: new () => any,
program: any,
): OptionsConfig {
const config: OptionsConfig = {};
for (const k of Object.keys(program)) {
const def = getOptionMetadata(Program, k);
if (!def) continue;
// получаем информацию из типов 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 и постами о ретроиграх ????️