Хабрахабр

[Из песочницы] Типизированный DSL в TypeScript из JSX

По сути, это создает возможность писать типизированный DSL используя JSX. У TypeScript есть встроенная поддержка JSX синтаксиса и компилятор TypeScript'а предоставляет годные инструменты по настройке процесса компиляции JSX. Заинтересовавшихся прошу под кат. В этой статье речь пойдет именно про это — как написать DSL из г с помощью JSX.

Репозиторий с готовым примером.

Пример не из веба позволит продемонстрировать что возможности JSX не ограничены React'ом, его компонентами и генерацией html в общем случае. В этой статье я не буду показывать возможности на примерах, относящихся к вебу, React'у и подобным. В этой статьи я покажу как реализовать DSL для генерации объектов сообщений для Slack.

Это маленькая фабрика сообщений одного и того же типа: Вот код который мы возьмем за основу.

interface Story
} const template = (username: string, stories: Story[]) => ({ text: `:wave: Привет ${username}, зацени наши последние статьи.`, attachments: stories.map(s => ({ title, color: '#000000', title_link: s.link, author_name: s.author.name, author_icon: s.author.avatarURL, text: `Опубликовано в _${s.publishedAt}_.` })
})

Например, обратите внимание на непонятно к чему относящееся свойство color, на два поля для заголовка (title и title_link) или на подчеркивания в text (текст внутри _ будет курсивом). Вроде бы выглядит неплохо, но тут есть один момент который можно значительно улучшить — читабельность. И вот с такими проблемами DSL и должны помогать. Все это мешает нам разделять контент от стилистических деталей, усложняя поиск того что важно.

Вот тот же пример только уже написанный в JSX:

const template = (username: string, stories: Story[]) => <message> :wave: Привет ${username}, зацени наши последние статьи. {stories.map(s => <attachment color='#000000'> <author icon={s.author.avatarURL}>{s.author.name}</author> <title link={s.link}>{s.title}</title> Опубликовано в <i>{s.publishedAt}</i>. </attachment> )} </message>

Все что должно жить вместе объединилось, стилистические детали и контент четко разделены — красота одним словом. Намного лучше!

Пишем DSL

Настроившем проект

Сначала необходимо включить JSX в проекте и сказать компилятору что мы не используем React, что наш JSX нужно компилировать иначе.

// tsconfig.json
{ "compilerOptions": { "jsx": "react", "jsxFactory": "Template.create" }
}

А опция "jsxFactory" настраивает компилятор на использование нашей фабрики JSX элементов. "jsx": "react" включает поддержку JSX в проекте и компилятор компилирует все JSX элементы в вызовы React.createElement.

После этих нехитрых настроек код вида:

import * as Template from './template' const JSX = <message>Text with <i>italic</i>.</message>

будет компилироваться в

const Template = require('./template'); const JSX = Template.create('message', null, 'Text with ', Template.create('i', null, 'italic'), '.');

Опишем JSX теги

Для этого мы задействуем одну из классных возможностей TypeScript'а — а именно локальные декларации пространств имен. Теперь, когда компилятор знает во что компилировать JSX, нам нужно объявить сами теги. Компилятор их цепляет и использует для проверки типов и для подсказок. Для случая с JSX TypeScript ожидает что в проекте есть пространство имен JSX(конкретная локация файла не имеет значения) с интерфейсом IntrinsicElements в котором и описаны сами теги.

// jsx.d.ts
declare namespace JSX { interface IntrinsicElements { i: {} message: {} author: { icon: string } title: { link?: string } attachment: { color?: string } }
}

По сути, имя ключа в интерфейсе это название самого тега который будет доступен в коду. Здесь мы объявили все JSX теги для нашего DSL и все их атрибуты. У некоторых тегов (i в нашем случае) может и не быть никаких атрибутов, у других опциональные или вообще необходимые. Значение это описание доступных атрибутов.

Собственно фабрика — Template.create

Она будет использоваться в рантайме для создания объектов. Наша фабрика из tsconfig.json и есть предмет разговора.

В самом простом случае она может выглядеть как-то так:

type Kinds = keyof JSX.IntrinsicElements // Имена всех тегов
type Attrubute<K extends Kinds> = JSX.IntrinsicElements[K] // и их атрибуты export const create = <K extends Kinds>(kind: K, attributes: Attrubute<K>, ...children) => { switch (kind) { case 'i': return `_${chidlren.join('')}_` default: // ... }
}

Проблемы начинаются со сложными тегами. Теги которые добавляют только стили к тексту внутри легко написать (i в нашем случае): наша фабрика просто заворачивает содержимое тега в строку с _ с обеих сторон. В чем же собственно проблема? Большую часть времени я провозился именно с ними, в поисках решения почище.

Что и близко не стояло с типизированным DSL, ну это ладно, вторая часть проблемы в том что тип у всех тегов будет один после прохода через фабрику — это ограничение самого JSX (у React'а все теги преобразуются в ReactElement). А она в том, что компилятор выводит тип <message>Text</message> в any.

Дженерики идут на помощь!

// jsx.d.ts
declare namespace JSX { interface Element { toMessage(): { text?: string attachments?: { text?: string author_name?: string author_icon?: string title_link?: string color?: string }[] } } interface IntrinsicElements { i: {} message: {} author: { icon: string } title: { link?: string } attachment: { color?: string } }
}

Это тоже стандартное поведение компилятора — использовать JSX. Добавился только Element и теперь компилятор будет выводить все JSX теги в тип Element. Element как тип для всех тегов.

К сожалению он будет работать не всегда, только на самом верхнеуровневом теге <message/> и это будет в райнтайме. У нашего Element есть только один общий метод — приведение его к типу объекта сообщения.

А под спойлером полная версия нашей фабрики.

Собственно код фабрики

import { flatten } from 'lodash' type Kinds = keyof JSX.IntrinsicElements // Имена всех тегов
type Attrubute<K extends Kinds> = JSX.IntrinsicElements[K] // и их атрибуты const isElement = (e: any): e is Element<any> => e && e.kind const is = <K extends Kinds>(k: K, e: string | Element<any>): e is Element<K> => isElement(e) && e.kind === k /* Конкатенация всех прямых потомков которые не являются элементам (строки) */
const buildText = (e: Element<any>) => e.children.filter(i => !isElement(i)).join('') const buildTitle = (e: Element<'title'>) => ({ title: buildText(e), title_link: e.attributes.link
}) const buildAuthor = (e: Element<'author'>) => ({ author_name: buildText(e), author_icon: e.attributes.icon
}) const buildAttachment = (e: Element<'attachment'>) => { const authorNode = e.children.find(i => is('author', i)) const author = authorNode ? buildAuthor(<Element<'author'>>authorNode) : {} const titleNode = e.children.find(i => is('title', i)) const title = titleNode ? buildTitle(<Element<'title'>>titleNode) : {} return { text: buildText(e), ...title, ...author, ...e.attributes }
} class Element<K extends Kinds> { children: Array<string | Element<any>> constructor( public kind: K, public attributes: Attrubute<K>, children: Array<string | Element<any>> ) { this.children = flatten(children) } /* * Конвертация элемента в тип сообщения работает только с тегом `<message/>` */ toMessage() { if (!is('message', this)) return {} const attachments = this.children.filter(i => is('attachment', i)).map(buildAttachment) return { attachments, text: buildText(this) } }
} export const create = <K extends Kinds>(kind: K, attributes: Attrubute<K>, ...children) => { switch (kind) { case 'i': return `_${children.join('')}_` default: return new Element(kind, attributes, children) }
}

Репозиторий с готовым примером.

Вместо заключения

Сейчас его возможностей еще больше и фабрику можно написать чище. Когда я делал эти свои опыты у команды TypeScript'а только появлялось понимание мощи и ограничений того что они сделали с JSX. Если появится желание покопаться и улучшить репозиторий с примером — велкам с пулл реквестами.

Теги
Показать больше

Похожие статьи

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть