← Back to blog

Neovim Treesitter Highlighting with SQL Generic Types (TypeScript)

sqltoolingtypescriptneovim

Neovim Treesitter Highlighting with SQL Generic Types (TypeScript)

TL;DR

  • Create queries/typescript/injections.scm
  • Paste the SQL injection query below
  • Enjoy perfect SQL highlighting for all your sql and tx template strings

Requirements: Neovim 0.9+ with Treesitter enabled


If you've ever written raw SQL queries inside TypeScript template literals, you probably love Treesitter's syntax highlighting for embedded languages like:

const result = await sql`SELECT * FROM users`;

But what happens when your code starts getting fancy — say, using generic calls, non-null assertions, or different helper functions like tx.sql or sql<User>?

By default, Treesitter doesn't inject SQL highlighting in these more complex patterns. In this post, we'll fix that by writing a custom Treesitter injection query to handle generic TypeScript expressions with sql and tx.


The Problem

Neovim's Treesitter injection for SQL works great in the simplest case:

const users = await sql`SELECT * FROM users`;

…but once you introduce TypeScript generics or non-null assertions, highlighting breaks:

// Generic instantiation
const result = await sql<User>`SELECT * FROM users`;
 
// Non-null expression
const result = (await sql<User>!)`SELECT * FROM users`;

The reason? Treesitter doesn't recognize that sql<User> (an instantiation_expression) still represents a function call for the purpose of language injection.


The Fix: Custom Treesitter Injection Query

To solve this, we extend Neovim's built-in SQL injection rules using a custom .scm query file.

Create a file at:

~/.config/nvim/queries/typescript/injections.scm

Paste this code:

;; Extend built-in injections
(call_expression
  function: (non_null_expression
    (instantiation_expression
      (await_expression
        (identifier) @_name)))
  arguments: (template_string) @injection.content
  (#eq? @_name "sql")
  (#set! injection.language "sql")
  (#set! injection.include-children))
 
;; Handle simpler variants like sql`...` and await sql`...`
(call_expression
  function: [
    (identifier) @_name
    (await_expression (identifier) @_name)
    (instantiation_expression function: (identifier) @_name)
  ]
  arguments: (template_string) @injection.content
  (#eq? @_name "sql")
  (#set! injection.language "sql")
  (#set! injection.include-children))
 
;; Support tx`...` and tx<User>`...`
(call_expression
  function: (non_null_expression
    (instantiation_expression
      (await_expression
        (identifier) @_name)))
  arguments: (template_string) @injection.content
  (#eq? @_name "tx")
  (#set! injection.language "sql")
  (#set! injection.include-children))
 
;; Handle simpler variants for tx as well
(call_expression
  function: [
    (identifier) @_name
    (await_expression (identifier) @_name)
    (instantiation_expression function: (identifier) @_name)
  ]
  arguments: (template_string) @injection.content
  (#eq? @_name "tx")
  (#set! injection.language "sql")
  (#set! injection.include-children))

What This Query Does

Let's break it down.

Treesitter parses TypeScript code into syntax nodes like:

  • call_expression — a function call (sql...``)
  • instantiation_expression — a generic function call (sql<User>)
  • await_expression — for await sql...``
  • template_string — your backticked SQL string

We're telling Treesitter:

"Whenever you see a call_expression whose function name is sql or tx, treat the argument (a template string) as SQL content."

The #set! injection.language "sql" command tells Neovim to apply SQL highlighting within that string.

We also include multiple variations to cover:

  • sql...``
  • await sql...``
  • sql<User>...``
  • await sql<User>...``
  • tx...``
  • await tx<User>...``

Testing It

Once you've saved the query, restart Neovim and open a TypeScript file containing SQL template strings.

Try all these patterns:

const a = sql`SELECT * FROM users`;
const b = await sql`SELECT * FROM posts`;
const c = await sql<User>`SELECT * FROM users`;
const d = await tx<Post>`SELECT * FROM posts`;
const e = tx`SELECT * FROM comments`;

If everything worked, your SQL inside backticks should now be properly highlighted in all cases.


Bonus Tip: Reload Queries Without Restarting Neovim

You can quickly reload queries after editing them using:

:TSBufReload

or restart Treesitter highlighting manually with:

:edit

Conclusion

By extending Treesitter's injection rules, we've made Neovim smart enough to recognize complex TypeScript SQL calls — even with generics, awaits, or alternate function names.

This small tweak can massively improve readability and developer experience when working with query-heavy TypeScript codebases.

Related Posts