Типизированный DSL в TypeScript из JSX


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


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


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


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


interface Story { 
  title: string
  link: string
  publishedAt: Date
  author: { name: string, avatarURL: string }
}

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"
  }
}

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


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


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 теги


Теперь, когда компилятор знает во что компилировать JSX, нам нужно объявить сами теги. Для этого мы задействуем одну из классных возможностей TypeScript'а — а именно локальные декларации пространств имен. Для случая с 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 в нашем случае): наша фабрика просто заворачивает содержимое тега в строку с _ с обеих сторон. Проблемы начинаются со сложными тегами. Большую часть времени я провозился именно с ними, в поисках решения почище. В чем же собственно проблема?


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


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


// 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
    }
  }
}

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


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


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


Собственно код фабрики
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. Сейчас его возможностей еще больше и фабрику можно написать чище. Если появится желание покопаться и улучшить репозиторий с примером — велкам с пулл реквестами.

  • +29
  • 4,4k
  • 2
Поделиться публикацией

Комментарии 2

    0

    Несмотря на все старания по избавлению от XML в качестве формата данных, он все равно приходит к нам обратно...

      –1
      Никуда он не приходит. Автор сравнивает несравнимое, а именно плоский json и многоуровневый html.

      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}_.`
        })
      })


      Вот его пример, всё находится тут:

      author_name: s.author.name,
      author_icon: s.author.avatarURL,
      

      и тут:

       title,
       color: '#000000',
       title_link: s.link,


      Тут идёт разложение объекта title(text, link) на плоский объект. Тоже самое с author(name, icon).

      Например, обратите внимание на непонятно к чему относящееся свойство color

      А это просто глупо.

      А тут мы видим, что изначально автор использовал не-плоские объекты:

      interface Story { 
        title: string
        link: string
        publishedAt: Date
        author: { name: string, avatarURL: string }
      }


      И тут у него не возникало вопроса «а к чему относится title». В конечном итоге автор решал проблему с форматом данных в каком-то левом api и это проблемы тех, кто организовывал эти форматы.

      В конечном итоге проблема решается явной типизацией/неймингом объектов. т.к. типизация в ts очень слаба, а в js нет тех же именованных аргументов — это проблема(для произвольных данных). В том же дарте мы можем написать так:

      messate(title: "", attachments: stories.map(s => attachment(color: "", author: author(icon: "", name: ""), title: title(link: "", title: ""));


      Тут также видно слабость типизации, ведь могли иметь не произвольные именованные аргументы, а произвольные типизированные аргументы(т.е. аргументы, которые выбираются исходя из типа, а не из имени), тогда бы мы просто написали:

      messate(text_obj, stories.map(s => attachment(link_obj, author(name_obj, icon_obj), title(link_obj, title_text));


      И после этого мы явно видим дыры в логике автора, когда у него внутри тега то name, то text, то title. Это не типизация, а дыра т.к. нельзя заранее сказать «что же там написано в chillds».

      В ситуации указанной автором в примере выше даже не нужны «произвольные типизированные аргументы», ведь формат сообщений/вложений фиксирован. Спокойно используется последний случай.

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое