Inferring TypeScript types from Mustache-templates

Mustache is a pretty good templating libary, but it doesn’t play well with TypeScript. This blog adopted Mustache at its inception and hasn’t changed much since, though missing and incorrect template parameters have caused a few instances of heartburn over the years. With the core engine written in TypeScript, template rendering has been one of the last gaps in type safety when rolling out changes to the blog or its content.

You see where this is going.

Skipping to the punchline, ts-mustache does the dirty work of turning the indeterminate references in a Mustache template into best-guess TypeScript type declarations. If you want the whole story, read on.

Why bother?

Consider a fairly simple template:

{{#entities}}
  {{#person}}
    {{name}}
  {{/person}}
{{#entities}}

A wonderful, awful thing about Mustache is the heroic efforts it will undertake to resolve and/or handle missing data at runtime. If it can’t find {{name}} in the context of a {{#person}}, it will visit previously-seen context for any variable called name and attempt to fill it in. Further, while a parser can determine that a variable called name exists, the template gives no concrete evidence of its type (integer? string? vegetable? mineral?) or nullability.

“Your scientists were so preoccupied with whether they could, they didn’t stop to think if they should.”

Jeff Goldblum

“But why bother!?” you cry. “There are plenty of React-as-CMS options on the market that include static typing right out of the box.”

And of course you’d be right. But a personal website is also (and should always be) a self-indulgent sandbox for experimenting with both old and new technology.

Plus, there are still a lot of old Mustache templates out in the world. A small increase in safety for the teams maintaining and using them adds up quickly, so maybe–just maybe–even generous TypeScript bindings are a tool worth having.

The approach

A loosely-defined template library begets loosely-defined types. Put another way, there are multiple valid ways to produce type definitions for a Mustache template, and the specific patterns and idioms in a specific context (codebase) may benefit from different representations.

Using the defaults from the ts-mustache library, it’s possible to generate one valid representation as follows:

import { DefaultLoader, Declarer } from 'ts-mustache'

const loader = new DefaultLoader({
  dir: './templates',
})

// Generate typedefs
const declarer = new Declarer(loader)
declarer.declare()
  .then(types => fs.writeFileSync('./mustacheTypes.ts', types)

The library is organized around its own internal representation of a Mustache Template, enabling it to separately consider the tasks of:

  1. translating a parsed template into a list of candidate type definitions
  2. rendering TS type declarations

(1) shouldn’t change without serious (and unexpected) changes to the Mustache specification. (2) is where the action is.

The input to the Renderer class used by the Declarer is the output of a Parser: a map to nodes in the parsed template, along with candidate resolutions for how a type could be represented:

type ResolutionCandidate =
  | { type: 'VALUE' }
  | { type: 'OPTIONAL' }
  | { type: 'RECORD'; typeName: string }
  | { type: 'SECTION'; typeName: string }

export type Resolution = {
  typeName: string
  candidates: Record<string, ResolutionCandidate[]>
}

export type ResolutionMap = Map<NodeId, Resolution>

Note that the candidates are represented as a list: the same variable seen multiple times inside the same Mustache template may suggest different shapes of input data, which the Renderer must reconcile into a final type definition.

Depending on its implementation, a Renderer could decide to represent multiple candidate resolutions as:

  • the most “permissive” representation in the list
  • an interface type extending multiple representations
  • a union or merged type covering all possibilities

The default Declarer class in ts-mustache attempts an imperfect balancing act between overly-restrictive and so-broad-as-to-be-functionally-useless type declarations, but leaves open the possibility for customization by extension of its underlying Renderer.

Using typed templates

Once types exist, ts-mustache also includes a top-level Renderer class to make it easy to use them. Given the TemplateMap produced by a Declarer, enforcing the definitions is as creating a new Renderer instance around it:


import { Renderer } from 'ts-mustache'
import { TemplateMap } from './mustacheTypes'

const renderer = new Renderer<TemplateMap>(loader)

renderer.render('post', { title: 'Foobar' })

The declarations still need to be rebuilt when templates change (a filesystem watcher like nodemon is an incredible friend in this), but subsequent calls to render templates will now be checked against the inferred types.

Conclusion

The nature of Mustache’s spec and implementation precludes a perfect answer to type generation, but sensible defaults and a bit of fine-tuning enable vast developer experience improvements over no types at all.

Check out ts-mustache on Github. And let me know how it goes!

Featured