TypeScript's JSX as a typed DSL
TypeScript has support for JSX and TypeScript's compiler provides really nice tools to customize how JSX will be compiled and ultimately gives the ability to write DSL over JSX that will be type-checked on compile time. This article is exactly about that - how to implement a DSL over JSX.
As an example of a JSX DSL, I won't use anything related to web or React to give you a hint that TypeScript's JSX is not limited anyhow to React components or rendering. I'll implement DSL for type-checked rich Slack message templates.
For example, this is a Slack message template constructed with objects.
interface Story {
title: string
link: string
publishedAt: Date
author: { name: string, avatarURL: string }
}
const template = (username: string, stories: Story[]) => ({
text: `:wave: Hi ${username}, check out our recent stories.`,
attachments: stories.map(s => ({
title,
color: '#000000',
title_link: s.link,
author_name: s.author.name,
author_icon: s.author.avatarURL,
text: `Published at _${s.publishedAt}_.`
})
})
It looks OK, but here is a thing that we can improve — readability. For example, take a look at color
property in the attachments or title_link
along with those _
(italics in Slack) in the text. They mess with the content and make it harder to distinguish what’s important and what’s not. Our template DSL can solve this problem.
Next example describes exactly the same message but with the DSL we gonna implement.
const template = (username: string, stories: Story[]) => (
<message>
:wave: Hi {username}, check out our recent stories.
{stories.map(s => (
<attachment color='#000000'>
<author icon={s.author.avatarURL}>{s.author.name}</author>
<title link={s.link}>{s.title}</title>
Published at <i>{s.publishedAt}</i>.
</attachment>
))}
</message>
)
Second example is much better — clear separation of content from styling. So... let's implement that DSL.
Project configuration
First of all, we have to enable JSX syntax in TypeScript project and tell the compiler that we don’t use React and need JSX to be compiled differently.
// tsconfig.json
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "Template.create"
}
}
Option "jsx": "react"
enables support for JSX syntax in the project and compiles all JSX elements to calls to React.createElement
. And then with the option "jsxFactory"
we tell the compiler that we don’t use React and need it to compile JSX tags to calls to Template.create
function.
And now
import * as Template from './template'
const JSX = (
<message>
Text with <i>italic</i>.
</message>
)
Roughly compiles to
const Template = require('./template')
const JSX = Template.create('message', null, 'Text with ', Template.create('i', null, 'italic'), '.')
JSX tags
Now compiler knows to what JavaScript function calls JSX syntax should be compiled and it’s time to actually define the DSL tags.
For this purpose, we will use really cool feature of TypeScript — in-project namespace definitions. In fact, we need to define name and attributes of each JSX tag and to do so we have to define namespace JSX with interface IntrinsicElements and TypeScript’s compiler and language services will pick them up and use for type-checking and autocompletion.
/* Picked up by compiler automatically */
declare namespace JSX {
interface IntrinsicElements {
i: {}
message: {}
author: { icon: string }
title: { link?: string }
attachment: {
color?: string
}
}
}
Here we defined all JSX tags from the example with all their attributes. So the name of a key in the interface is the actual name of the tag and right side is a definition of its attributes. Note that some tags don’t have any attributes like i
and message
while others have optional attributes.
Factory
At this moment we are done with compile-time related code and should define factory function for the JSX tags for runtime. Remember that Template.create
from the tsconfig.json
?
type Kinds = keyof JSX.IntrinsicElements // Tag names
type Attrubute<K extends Kinds> = JSX.IntrinsicElements[K] // Tag attributes
export const create = <K extends Kinds>(kind: K, attributes: Attrubute<K>, ...children) => {
switch (kind) {
case 'i':
return `_${chidlren.join('')}_`
default: // ...
}
}
Aha, now we have a basic definition of Template
.
Tags that just add styling to the text like i
tag are easy. We just return their content as string wrapped in _
. But with more complex tags it’s not that obvious what to do. In fact most of the time I did spend on this part — trying to come up with a good solution. So what’s the problem?
Problem right now is that TypeScript compiler infers type of <message>Text</message>
to be any
. Which isn’t close to the goal of type-checked DSL. And thing is, there is no way to explain to TypeScript compiler the type of result for each tag because of the limitation of JSX in general — all tags produce the same type(which works for React — everything is a React.Component
).
So the solution I came up is really simple — describe some common type for each tag and use it as intermediate state. Good news is TypeScript allows defining type that will be used for all tags.
/* Picked up by compiler automatically */
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
}
}
}
We just added Element
type and TypeScript now infers the type of each JSX block to be of type Element
. This is a default behavior of TypeScript compiler on JSX namespace. It uses the interface with name Element
as a type for each JSX block.
Now we can go back to Template
and finish its implementation to return object matching this interface.
import { flatten } from 'lodash'
type Kinds = keyof JSX.IntrinsicElements // Tag names
type Attrubute<K extends Kinds> = JSX.IntrinsicElements[K] // Tag attributes
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
/* Concat all direct child nodes that aren't 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)
}
/*
* Convert this Element to actual Slack message
* only if it is a higher level Element — <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)
}
}
This is it, now we can compile the example and it will produce correct Slack message object.
I’m pretty sure TypeScript guys didn’t intent JSX syntax to be used this way, but this seems to be useful or at least very interesting to play around.