Почему ваш проект заслуживает собственного языка?
Представьте: вы пишете код для расчёта страховых премий, и вместо десятков вложенных if-else и магических чисел видите лаконичную конструкцию:
const rule = new InsuranceRule()
.when(age > 25)
.and(experience > 2)
.then(premium = base * 0.8);
Это не магия. Это DSL (Domain Specific Language) — предметно-ориентированный язык, встроенный в TypeScript. В этой статье мы разберём, как создавать такие языки, чтобы ваш код говорил на языке бизнеса, а не на языке машин.
Что такое DSL и зачем он нужен?
DSL — это язык, созданный для решения задач одной конкретной предметной области. В отличие от языков общего назначения (Java, C#, TypeScript), DSL не пытается быть универсальным. Он жертвует гибкостью ради выразительности.
Когда стоит использовать DSL:
- Бизнес-логика часто меняется и её нужно дать править аналитикам
- В проекте много повторяющихся паттернов конфигурации
- Нужно изолировать сложные алгоритмы от остального кода
- Требуется валидация данных на уровне грамматики
TypeScript для создания DSL — идеальный выбор. Статическая типизация, мощная система типов и возможность компилироваться в JavaScript делают его гибким инструментом для построения как внутренних, так и внешних языков.
Внутренние DSL: fluent API и цепочки вызовов
Самый простой способ создать DSL в TypeScript — использовать Fluent Interface (текучий интерфейс). Это когда каждый метод возвращает сам объект, позволяя строить цепочки вызовов.
Пример: конструктор SQL-запросов
class QueryBuilder {
private table: string = '';
private conditions: string[] = [];
private selectedFields: string[] = ['*'];
select(...fields: string[]): this {
this.selectedFields = fields.length ? fields : ['*'];
return this;
}
from(table: string): this {
this.table = table;
return this;
}
where(condition: string): this {
this.conditions.push(condition);
return this;
}
and(condition: string): this {
return this.where(condition);
}
build(): string {
let sql = `SELECT ${this.selectedFields.join(', ')} FROM ${this.table}`;
if (this.conditions.length) {
sql += ` WHERE ${this.conditions.join(' AND ')}`;
}
return sql;
}
}
// Использование
const query = new QueryBuilder()
.select('name', 'age')
.from('users')
.where('age > 18')
.and('status = "active"')
.build();
console.log(query);
// SELECT name, age FROM users WHERE age > 18 AND status = "active"
Это классический внутренний DSL на TypeScript. Он не требует отдельного парсера и работает внутри TypeScript-кода. Главный плюс — полная поддержка автодополнения и типизации.
Усложняем: типизированный DSL для бизнес-правил
Добавим строгую типизацию с помощью дженериков и union-типов:
type ComparisonOperator = '>' | '<' | '=' | '!=';
class RuleBuilder<T extends Record<string, any>> {
private conditions: ((data: T) => boolean)[] = [];
when<K extends keyof T>(field: K, op: ComparisonOperator, value: T[K]): this {
this.conditions.push((data: T) => {
switch (op) {
case '>': return data[field] > value;
case '<': return data[field] < value;
case '=': return data[field] === value;
case '!=': return data[field] !== value;
}
});
return this;
}
and<K extends keyof T>(field: K, op: ComparisonOperator, value: T[K]): this {
return this.when(field, op, value);
}
evaluate(data: T): boolean {
return this.conditions.every(c => c(data));
}
}
// Тип данных для проверки
interface UserData {
age: number;
name: string;
isActive: boolean;
}
const rule = new RuleBuilder<UserData>()
.when('age', '>', 18)
.and('isActive', '=', true);
console.log(rule.evaluate({ age: 25, name: 'Alice', isActive: true })); // true
console.log(rule.evaluate({ age: 16, name: 'Bob', isActive: true })); // false
Обратите внимание: TypeScript сам подсказывает, какие поля доступны, и не даст передать строку туда, где ожидается число. Это и есть сила предметно-ориентированных языков в TypeScript.
Внешние DSL: когда нужен свой синтаксис
Иногда возможностей fluent API недостаточно. Например, вы хотите, чтобы бизнес-аналитик писал правила на почти естественном языке. Тогда нужен внешний DSL — отдельный язык со своим синтаксисом и парсером.
Шаг 1. Определяем грамматику
Допустим, мы хотим язык для описания ценовых скидок:
// discount.dsl
DISCOUNT "Summer Sale"
IF total > 1000 AND client_type = "vip"
APPLY 15% OFF
ELSE IF total > 500
APPLY 5% OFF
Шаг 2. Пишем лексер и парсер
Для простоты используем регулярные выражения. В реальных проектах лучше взять библиотеку вроде chevrotain или nearley.
type Token =
| { type: 'KEYWORD'; value: string }
| { type: 'IDENTIFIER'; value: string }
| { type: 'NUMBER'; value: number }
| { type: 'STRING'; value: string }
| { type: 'OPERATOR'; value: string }
| { type: 'PERCENT'; value: number };
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
const regex = /\s*(DISCOUNT|IF|ELSE|APPLY|OFF|AND|\d+%|>|<|=|\w+|"[^"]*")\s*/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(input)) !== null) {
const val = match[1];
if (['DISCOUNT', 'IF', 'ELSE', 'APPLY', 'OFF', 'AND'].includes(val)) {
tokens.push({ type: 'KEYWORD', value: val });
} else if (val === '>' || val === '<' || val === '=') {
tokens.push({ type: 'OPERATOR', value: val });
} else if (/^\d+%$/.test(val)) {
tokens.push({ type: 'PERCENT', value: parseInt(val) });
} else if (/^\d+$/.test(val)) {
tokens.push({ type: 'NUMBER', value: parseInt(val) });
} else if (/^"[^"]*"$/.test(val)) {
tokens.push({ type: 'STRING', value: val.slice(1, -1) });
} else {
tokens.push({ type: 'IDENTIFIER', value: val });
}
}
return tokens;
}