In this post, I show how to setup Emacs for TypeScript and React (tsx) development, with tree-sitter for syntax highlighting and indentation, and LSP with the TypeScript compiler (including a plugin for faster eslint), via eglot, for code intelligence.

First some background on tree-sitter

Tree-sitter is a surprising and slightly revolutionary recent development (2017) in the world of code editors. It is both a parser generator tool and an incremental parsing library that works for multiple programming languages.

Up until recently (well, until tree-sitter came along), the norm for interactive code editors was to use a mishmash of regular expressions in order to understand source code well enough to be able to perform syntax highlighting quickly enough for interactive use.

In addition to being able to parse a file from scratch really quickly (the parser generator produces dependency-free C code, which helps), incremental parsing means that tree-sitter is able to transform changes to the file to incremental updates of the parsed syntax tree really efficiently.

In practice, this means that syntax highlighting can be done much more accurately than with regular expressions, and can be updated continuously as you type.

Screenshots of the end-result

Figure 1: App.tsx from the example repo showing tree-sitter syntax highlighting, eslint issues, function docs via eglot and lsp, yarn start watcher in ansi-term.

Figure 1: App.tsx from the example repo showing tree-sitter syntax highlighting, eslint issues, function docs via eglot and lsp, yarn start watcher in ansi-term.

Figure 2: Completion at point with minad’s corfu.el

Figure 2: Completion at point with minad’s corfu.el

Configure tree-sitter in Emacs for general use

Although there is an effort underway to integrate tree-sitter with Emacs core, you can already start using this functionality through the tree-sitter Emacs package.

(The current norm in Emacs is the bag-of-regular-expressions mentioned above. I really hope that tree-sitter will take over as the default soon.)

At this point, you can use tree-sitter for faster and more accurate syntax highlighting in Emacs, but having a full always-up-to-date syntax tree available will enable more broadly powerful techniques such as structural editing.

By adding the code below to your init, you’ll automatically get tree-sitter syntax highlighting for all supported languages:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
(use-package tree-sitter
  :ensure t
  :config
  ;; activate tree-sitter on any buffer containing code for which it has a parser available
  (global-tree-sitter-mode)
  ;; you can easily see the difference tree-sitter-hl-mode makes for python, ts or tsx
  ;; by switching on and off
  (add-hook 'tree-sitter-after-on-hook #'tree-sitter-hl-mode))

(use-package tree-sitter-langs
  :ensure t
  :after tree-sitter)

Ensure for TSX, configure for tree-sitter-based indentation

The following configuration will ensure that tree-sitter’s dedicated tsx parser will be used for tsx (typescript + react) files. By default this currently is not the case, as it uses the typescript parser which does not understand the tsx extensions.

Here we create a new derived mode that will map to both .tsx and .ts. Due to the derived mode’s name, the typescript language server will select tsx support, and due to the the explicit mapping, tree-sitter will select its tsx parser.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
(use-package typescript-mode
  :after tree-sitter
  :config
  ;; we choose this instead of tsx-mode so that eglot can automatically figure out language for server
  ;; see https://github.com/joaotavora/eglot/issues/624 and https://github.com/joaotavora/eglot#handling-quirky-servers
  (define-derived-mode typescriptreact-mode typescript-mode
    "TypeScript TSX")

  ;; use our derived mode for tsx files
  (add-to-list 'auto-mode-alist '("\\.tsx?\\'" . typescriptreact-mode))
  ;; by default, typescript-mode is mapped to the treesitter typescript parser
  ;; use our derived mode to map both .tsx AND .ts -> typescriptreact-mode -> treesitter tsx
  (add-to-list 'tree-sitter-major-mode-language-alist '(typescriptreact-mode . tsx)))

Thanks to Dan Orzechowski, we can configure the tsi.el package which will give us tree-sitter based indentation for TypeScript, JSON and (S)CSS.

The code below shows how to do this with use-package and quelpa, as tsi.el is not (yet) on melpa.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
;; https://github.com/orzechowskid/tsi.el/
;; great tree-sitter-based indentation for typescript/tsx, css, json
(use-package tsi
  :after tree-sitter
  :quelpa (tsi :fetcher github :repo "orzechowskid/tsi.el")
  ;; define autoload definitions which when actually invoked will cause package to be loaded
  :commands (tsi-typescript-mode tsi-json-mode tsi-css-mode)
  :init
  (add-hook 'typescript-mode-hook (lambda () (tsi-typescript-mode 1)))
  (add-hook 'json-mode-hook (lambda () (tsi-json-mode 1)))
  (add-hook 'css-mode-hook (lambda () (tsi-css-mode 1)))
  (add-hook 'scss-mode-hook (lambda () (tsi-scss-mode 1))))

Bonus: Auto-format all the things

I recently discovered apheleia (thank you /r/emacs/!), an emacs package that uses different formatting tools such as prettier and black to format your code on save, and then apply various clever algorithms to ensure that your cursor stays in the same place relatively speaking.

Install and activate as follows:

1
2
3
4
5
6
;; auto-format different source code files extremely intelligently
;; https://github.com/radian-software/apheleia
(use-package apheleia
  :ensure t
  :config
  (apheleia-global-mode +1))

typescript-language-server with eslint, LSP and eglot for code intelligence

Although I used to use lsp-mode, these days I am really enjoying the simplicity of eglot, and especially the fact that it relies as far as possible on standard Emacs facilities such as eldoc.

Below is the sum total of my eglot configuration:

1
2
(use-package eglot
  :ensure t)

Once that’s done, we need some system-wide configuration, most importantly the typescript-language-server that is invoked when you M-x eglot on a file that you’re editing:

1
2
3
4
5
# use nvm or fnm (much faster shel startup by default) to ensure node.js
fnm install 16
fnm use 16
# globally install prettier (for formatting via apheleia) and the TS language server
yarn global add prettier typescript-language-server

Local project configuration

Importantly, we have to install and configure the typescript-eslint-language-service in the local project dependencies for runtime eslint checking. This service re-uses the AST (parsed syntax tree) generated by the typescript compiler itself to check eslint rules.

First the installation of the dev dependency:

1
2
# and remember to add to tsconfig plugins
yarn add -D typescript-eslint-language-service

Then add the following to the compilerOptions in tsconfig.json to enable the service (merge with any existing plugins):

1
2
3
4
5
"plugins": [
  {
    "name": "typescript-eslint-language-service"
  }
]

I have prepared a small demo repo that has all of the local bits already setup: typescript-emacs-demo

I used create-react-app for this, and so it comes with the default eslint rules for that system.

To get up and running, clone the project and then do:

1
2
3
4
5
# install dependencies
yarn
# check manual eslint invocation output
# if breakTheLaw() in App.tsx is uncommented, eslint will show a bunch of violations
npx eslint .

With all of the above in place, you should be able to open src/App.tsx in Emacs, then do M-x eglot to activate for the whole project.

After some moments, you should see flymake in the modeline with note, warning and error counts. Invoke M-x flymake-show-buffer-diagnostics to activate the buffer with eslint results.

To see the difference tree-sitter makes in this case, toggle off with M-x tree-sitter-hl-mode.

Bonus: Additional details about the typescript language service

The following architecture layer diagram, found in this github comment (also read its informative text), helps to understand the design and operation of the typescript language service:

Using ps, you can confirm that eglot starts up the global typescript-language-server, which on its part invokes the tsserver.js that lives inside your project and is directly connected to its TypeScript compiler setup.