Typed DSL in TypeScript from JSX


TypeScript has built-in support for JSX syntax and TypeScript compiler provides useful tools for customizing the JSX compilation process. In essence, this creates the ability to write typed DSL using JSX. This article is about this - how to write DSLof gusing jsx. Interested please under the cat.


Repository with a ready-made example.


In this article I will not show the possibilities with examples related to the web, React'u and similar. An example not from the web will allow you to demonstrate that the JSX capabilities are not limited to React, its components and html generation in general. In this article, I will show how to implement DSL to generate message objects for Slack .


Here is the code that we take as a basis. This is a small message factory of the same type:


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

It seems to be looking good, but there is one thing that can be significantly improved - readability . For example, pay attention to is not clear what the property belonging coloron two fields for the header ( titleand title_link) or underscore text(text inside _will be italicized ). All this prevents us from separating content from stylistic details, complicating the search for what is important. And with such problems DSL should help.


Here is the same example just written in JSX:


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

Much better! All that should live together is united, the stylistic details and content are clearly separated - beauty in one word.


We write DSL


Customize the project


First you need to enable JSX in the project and tell the compiler that we do not use React, that our JSX needs to be compiled otherwise.


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

"jsx": "react"includes JSX support in the project and the compiler compiles all JSX elements into calls React.createElement. And the option "jsxFactory"configures the compiler to use our factory of JSX elements.


After these simple settings, the view code:


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

will compile to


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

Describe JSX tags


Now that the compiler knows what to compile JSX for, we need to declare the tags themselves. To do this, we will use one of TypeScript's cool features — namely, local namespace declarations. For the case of JSX TypeScript expects that the project has a namespace JSX(the specific location of the file does not matter) with the interface IntrinsicElementsin which the tags themselves are described. The compiler catches them and uses them for type checking and for hints.


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

Here we declared all JSX tags for our DSL and all their attributes. In essence, the name of the key in the interface is the name of the tag itself, which will be available in the code. Value is a description of available attributes. Some tags ( iin our case) may not have any attributes, others have optional or even necessary attributes.


Actually factory - Template.create


Our factory tsconfig.jsonis the subject of conversation. It will be used in runtime to create objects.


In the simplest case, it might look something like this:


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

Tags that add only styles to the text inside are easy to write ( iin our case): our factory simply wraps the contents of the tag in a line _with both sides. Problems start with complex tags. Most of the time I was busy with them, looking for a cleaner solution. What is the actual problem?


And it is that the compiler displays the type <message>Text</message>in any. That didn’t come close to a typed DSL, well, well, the second part of the problem is that all tags will have one type after passing through the factory - this is a limitation of JSX itself (React has all tags converted to ReactElement).


Generics go to the rescue!


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

It was added only Elementand now the compiler will display all JSX tags in the type Element. This is also the standard compiler behavior - use JSX.Elementas a type for all tags.


Ours Elementhas only one common method - to cast it to the type of the message object. Unfortunately, it will not always work, only on the top-level tag <message/>and it will be in RayTime.


And under the spoiler, the full version of our factory.


Factory code itself
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)
  }
}

Repository with a ready-made example.


Instead of conclusion


When I did these experiences, TypeScript’s team only had an understanding of the power and limitations of what they did with JSX. Now its capabilities are even greater and the factory can be written cleaner. If there is a desire to delve and improve the repository with an example - Wellcome with pull requests.


Also popular now: