Home Java Typed DSL in TypeScript from JSX

Typed DSL in TypeScript from JSX

by admin

Typed DSL in TypeScript from JSX

TypeScript has built-in support for JSXsyntax and the TypeScript compiler provides useful tools for customizing the JSX compilation process.Basically it creates the ability to write typed DSL using JSX. This article will do exactly that – how to write DSL from d with JSX. For those who are interested, please go under.

Repository with a ready-made example.

In this article I won’t show what I can do with examples related to the web, React and the like. This non-Web example will demonstrate that JSX features are not limited to React, its components, and html generation in general. In this article I will show how to implement DSL for generating Slack message objects

This is the code we will use as a base. This is a small message factory of the same type :

interface Story {title: stringlink: stringpublishedAt: Dateauthor: { name: string, avatarURL: string }}const template = (username: string, stories: Story[]) =>({text: `:wave: Hi ${username}, check out the latest articles.`, attachments: stories.map(s => ({title, color: '#000000', title_link: s.link, author_name: s.author.name, author_icon: s.author.avatarURL, text: 'Published in_${s.publishedAt}_`'})})

It looks good, but there is one point that could be greatly improved readability For example, note the obscurely related property color , the two fields for the header ( title and title_link ) or on the underscores in text (text inside _ will be in italics ). All of this prevents us from separating content from stylistic details, making it harder to find what’s important. And these are the kinds of problems DSLs are supposed to help with.

Here is the same example, only already written in JSX:

const template = (username: string, stories: Story[]) => <message>:wave: Hi${username}, check out our latest articles.{stories.map(s =><attachment color='#000000'><author icon={s.author.avatarURL}> {s.author.name}</author><title link={s.link}> {s.title}</title>Published in <i> {s.publishedAt}</i> .</attachment>)}</message>

Much better!!! Everything that should live together has come together, stylistic details and content are clearly separated – beauty in a word.

Let’s write DSL

Setting up the project

First you need to enable JSX in the project and tell the compiler that we don’t use React, that our JSX needs to compile otherwise.

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

"jsx": "react" enables 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 JSX element factory.

After these simple settings, the code looks like :

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

will be compiled into

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

Let’sdescribe JSX tags

Now that the compiler knows what to compile JSX into, we need to declare the tags themselves. To do this, we use one of TypeScript’s class features – namely, local namespace declarations. For the JSX case, TypeScript expects the project to have the namespace JSX (the specific location of the file is not important) with the interface IntrinsicElements where the tags themselves are described. The compiler catches them and uses them for checking types and hints.

// jsx.d.tsdeclare 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. Basically, the name of the key in the interface is the name of the tag itself which will be available in the code. The value is the description of the available attributes. Some of the tags ( i in our case) may not have anyattributes, others are optional or even necessary.

The factory itself is Template.create

Our factory of tsconfig.json is the subject of this talk. It will be used in runtime to create objects.

In the simplest case it might look something like this :

type Kinds = keyof JSX.IntrinsicElements // Names of all tagstype Attrubute<K extends Kinds> = JSX.IntrinsicElements[K] // and their attributesexport 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 just wraps the content of the tag in a string with _ on both sides. The problems start with complex tags. I spent most of my time with them, looking for a cleaner solution. What exactly is the problem?

And that is because the compiler outputs the type <message> Text</message> in any Which isn’t even close to typed DSL, but the second part of the problem is that all tags will have the same type after going through the factory – this is a limitation of JSX itself (React converts all tags to ReactElement).

Generics are coming to the rescue!

// jsx.d.tsdeclare namespace JSX {interface Element{toMessage(): {text?: stringattachments?: {text?: stringauthor_name?: stringauthor_icon?: stringtitle_link?: stringcolor?: string}[]}}interface IntrinsicElements {i: {}message: {}author: { icon: string }title: { link?: string }attachment: {color?: string}}}

Only added Element and now the compiler will output all JSX tags as Element This is also standard compiler behavior to use JSX.Element as a type for all tags.

Ours has Element has only one general method, which is to convert 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 ryntime.

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

The factory code itself

import { flatten }from 'lodash'type Kinds = keyof JSX.IntrinsicElements // Names of all tagstype Attrubute<K extends Kinds> = JSX.IntrinsicElements[K] // and their attributesconst isElement = (e: any): e is Element<any> =>e e.kindconst is = <K extends Kinds> (k: K, e: string | Element<any> ): e is Element<K> =>isElement(e) e.kind === k/* Concatenation of all direct descendants that are not elements (strings) */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)}/** Converting an element to a message type only works with the `message/gt;` tag*/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.

In lieu of conclusion

When I did these experiments of mine, the TypeScript team was just beginning to understand the power and limitations of what they did with JSX. There are even more possibilities now, and the factory can be written cleaner. If you feel like digging in and improving the example repository, welcome with the pooled reqests.

You may also like