Inferring TypeScript types from Mustache-templates
- 7/31/2023
- ·
- #typescript
- #devex
- #mustache
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.”
“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:
- translating a parsed template into a list of candidate type definitions
- 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!