DSL на TypeScript: создаём свой язык программирования за вечер

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему ваш проект заслуживает собственного языка?

Представьте: вы пишете код для расчёта страховых премий, и вместо десятков вложенных 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;
}

Шаг

Блоги

Рекомендаций по книгам