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 DSL of g using 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 capabilities of JSX 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 the color
property that is not clear to what, the two fields for the title ( title
and title_link
) or the underscores in text
(the 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'> <author icon={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.
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 React.createElement
calls. And the "jsxFactory"
option configures the compiler to use our JSX element factory.
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'), '.');
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 JSX
namespace (the specific location of the file does not matter) with the IntrinsicElements
interface in 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 the available attributes. Some tags ( i
in our case) may not have any attributes, others have optional or even necessary attributes.
Template.create
Our factory from tsconfig.json
is 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] // export const 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 ( i
in our case): our factory simply wraps the tag in a string with _
on both sides. Problems begin 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 } } }
Only Element
added, and now the compiler will output all JSX tags to the Element
type. This is also the standard compiler behavior - use JSX.Element
as the type for all tags.
Our Element
has only one common method - casting it to the type of the message object. Unfortunately, it will not always work, only on the top-level <message/>
and it will be in raintime.
And under the spoiler, the full version of our factory.
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.
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.
Source: https://habr.com/ru/post/433348/
All Articles