typebars 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +1582 -0
  2. package/dist/analyzer.d.ts +59 -0
  3. package/dist/analyzer.js +4 -0
  4. package/dist/analyzer.js.map +9 -0
  5. package/dist/chunk-1gm6cf0e.js +5 -0
  6. package/dist/chunk-1gm6cf0e.js.map +10 -0
  7. package/dist/chunk-1qwj7pjc.js +4 -0
  8. package/dist/chunk-1qwj7pjc.js.map +10 -0
  9. package/dist/chunk-4zv02svp.js +7 -0
  10. package/dist/chunk-4zv02svp.js.map +10 -0
  11. package/dist/chunk-60gk3q7z.js +5 -0
  12. package/dist/chunk-60gk3q7z.js.map +10 -0
  13. package/dist/chunk-6955jpr7.js +5 -0
  14. package/dist/chunk-6955jpr7.js.map +10 -0
  15. package/dist/chunk-6c0pw73w.js +7 -0
  16. package/dist/chunk-6c0pw73w.js.map +10 -0
  17. package/dist/chunk-7j6q1e3z.js +5 -0
  18. package/dist/chunk-7j6q1e3z.js.map +14 -0
  19. package/dist/chunk-8g0d6h85.js +5 -0
  20. package/dist/chunk-8g0d6h85.js.map +10 -0
  21. package/dist/chunk-9mg6qfrs.js +5 -0
  22. package/dist/chunk-9mg6qfrs.js.map +10 -0
  23. package/dist/chunk-a37yzqra.js +5 -0
  24. package/dist/chunk-a37yzqra.js.map +11 -0
  25. package/dist/chunk-awgj10qg.js +4 -0
  26. package/dist/chunk-awgj10qg.js.map +10 -0
  27. package/dist/chunk-fhvf5y4x.js +7 -0
  28. package/dist/chunk-fhvf5y4x.js.map +10 -0
  29. package/dist/chunk-ggdfaqhe.js +5 -0
  30. package/dist/chunk-ggdfaqhe.js.map +10 -0
  31. package/dist/chunk-hc1jnqaw.js +5 -0
  32. package/dist/chunk-hc1jnqaw.js.map +10 -0
  33. package/dist/chunk-kznb0bev.js +5 -0
  34. package/dist/chunk-kznb0bev.js.map +10 -0
  35. package/dist/chunk-mx8neh7q.js +5 -0
  36. package/dist/chunk-mx8neh7q.js.map +10 -0
  37. package/dist/chunk-p3xzf1ew.js +5 -0
  38. package/dist/chunk-p3xzf1ew.js.map +10 -0
  39. package/dist/chunk-qh2r1pa1.js +5 -0
  40. package/dist/chunk-qh2r1pa1.js.map +10 -0
  41. package/dist/chunk-qpzzr2rd.js +5 -0
  42. package/dist/chunk-qpzzr2rd.js.map +10 -0
  43. package/dist/chunk-vka4e61h.js +7 -0
  44. package/dist/chunk-vka4e61h.js.map +10 -0
  45. package/dist/chunk-xbvk4ygq.js +5 -0
  46. package/dist/chunk-xbvk4ygq.js.map +11 -0
  47. package/dist/chunk-ybh51hbe.js +7 -0
  48. package/dist/chunk-ybh51hbe.js.map +10 -0
  49. package/dist/chunk-yczpjh73.js +4 -0
  50. package/dist/chunk-yczpjh73.js.map +10 -0
  51. package/dist/chunk-yraqh2tz.js +5 -0
  52. package/dist/chunk-yraqh2tz.js.map +10 -0
  53. package/dist/chunk-z6yh5qvc.js +5 -0
  54. package/dist/chunk-z6yh5qvc.js.map +10 -0
  55. package/dist/compiled-template.d.ts +130 -0
  56. package/dist/compiled-template.js +4 -0
  57. package/dist/compiled-template.js.map +9 -0
  58. package/dist/errors.d.ts +93 -0
  59. package/dist/errors.js +4 -0
  60. package/dist/errors.js.map +9 -0
  61. package/dist/executor.d.ts +55 -0
  62. package/dist/executor.js +4 -0
  63. package/dist/executor.js.map +9 -0
  64. package/dist/helpers/helper-factory.d.ts +56 -0
  65. package/dist/helpers/index.d.ts +4 -0
  66. package/dist/helpers/logical-helpers.d.ts +15 -0
  67. package/dist/helpers/math-helpers.d.ts +13 -0
  68. package/dist/helpers/utils.d.ts +19 -0
  69. package/dist/index.d.ts +2 -0
  70. package/dist/index.js +4 -0
  71. package/dist/index.js.map +9 -0
  72. package/dist/parser.d.ts +146 -0
  73. package/dist/parser.js +4 -0
  74. package/dist/parser.js.map +9 -0
  75. package/dist/schema-resolver.d.ts +50 -0
  76. package/dist/schema-resolver.js +4 -0
  77. package/dist/schema-resolver.js.map +9 -0
  78. package/dist/typebars.d.ts +130 -0
  79. package/dist/typebars.js +4 -0
  80. package/dist/typebars.js.map +9 -0
  81. package/dist/types.d.ts +334 -0
  82. package/dist/types.js +4 -0
  83. package/dist/types.js.map +9 -0
  84. package/dist/utils.d.ts +113 -0
  85. package/dist/utils.js +4 -0
  86. package/dist/utils.js.map +9 -0
  87. package/package.json +42 -0
package/README.md ADDED
@@ -0,0 +1,1582 @@
1
+ # Typebars
2
+
3
+ **Type-safe Handlebars template engine with static analysis powered by JSON Schema.**
4
+
5
+ Typebars wraps Handlebars with a static analysis layer that validates your templates against a JSON Schema **before execution**. Given an input schema describing your data, Typebars detects unknown properties, type mismatches, and missing arguments at analysis time — and infers the exact JSON Schema of the template's output.
6
+
7
+ ```ts
8
+ import { Typebars } from "typebars";
9
+
10
+ const engine = new Typebars();
11
+
12
+ const inputSchema = {
13
+ type: "object",
14
+ properties: {
15
+ name: { type: "string" },
16
+ age: { type: "number" },
17
+ },
18
+ required: ["name", "age"],
19
+ };
20
+
21
+ // Analyze: validates the template against the input schema
22
+ // and infers the output schema
23
+ const { valid, diagnostics, outputSchema } = engine.analyze(
24
+ "Hello {{name}}, you are {{age}}!",
25
+ inputSchema,
26
+ );
27
+
28
+ valid; // true — every referenced variable exists in the schema
29
+ outputSchema; // { type: "string" } — mixed template always produces a string
30
+
31
+ // Now analyze a single expression
32
+ const result = engine.analyze("{{age}}", inputSchema);
33
+ result.outputSchema; // { type: "number" } — inferred from the input schema
34
+
35
+ ```
36
+
37
+ The output schema is inferred **statically** from the template structure and the input schema — no data is needed.
38
+
39
+ ---
40
+
41
+ ## Table of Contents
42
+
43
+ - [Installation](#installation)
44
+ - [Quick Start](#quick-start)
45
+ - [How It Works](#how-it-works)
46
+ - [Static Analysis — Input Validation](#static-analysis--input-validation)
47
+ - [Property Validation](#property-validation)
48
+ - [Nested Properties (Dot Notation)](#nested-properties-dot-notation)
49
+ - [Validation Inside Block Helpers](#validation-inside-block-helpers)
50
+ - [Diagnostics](#diagnostics)
51
+ - [Static Analysis — Output Schema Inference](#static-analysis--output-schema-inference)
52
+ - [Single Expression → Resolved Type](#single-expression--resolved-type)
53
+ - [Mixed Template → String](#mixed-template--string)
54
+ - [Single Block → Branch Type Inference](#single-block--branch-type-inference)
55
+ - [Object Templates → Object Schema](#object-templates--object-schema)
56
+ - [Literal Inputs → Primitive Schema](#literal-inputs--primitive-schema)
57
+ - [Schema Features](#schema-features)
58
+ - [`$ref` Resolution](#ref-resolution)
59
+ - [Combinators (`allOf`, `anyOf`, `oneOf`)](#combinators-allof-anyof-oneof)
60
+ - [`additionalProperties`](#additionalproperties)
61
+ - [Array `.length` Intrinsic](#array-length-intrinsic)
62
+ - [Execution & Type Preservation](#execution--type-preservation)
63
+ - [Compiled Templates](#compiled-templates)
64
+ - [Object Templates](#object-templates)
65
+ - [Block Helpers](#block-helpers)
66
+ - [`{{#if}}` / `{{#unless}}`](#if--unless)
67
+ - [`{{#each}}`](#each)
68
+ - [`{{#with}}`](#with)
69
+ - [Built-in Math Helpers](#built-in-math-helpers)
70
+ - [Built-in Logical & Comparison Helpers](#built-in-logical--comparison-helpers)
71
+ - [Why Sub-Expressions?](#why-sub-expressions)
72
+ - [Comparison Helpers](#comparison-helpers)
73
+ - [Equality Helpers](#equality-helpers)
74
+ - [Logical Operators](#logical-operators)
75
+ - [Collection Helpers](#collection-helpers)
76
+ - [Generic `compare` Helper](#generic-compare-helper)
77
+ - [Nested Sub-Expressions](#nested-sub-expressions)
78
+ - [Static Analysis of Sub-Expressions](#static-analysis-of-sub-expressions)
79
+ - [Custom Helpers](#custom-helpers)
80
+ - [`registerHelper`](#registerhelper)
81
+ - [`defineHelper` (Type-Safe)](#definehelper-type-safe)
82
+ - [Template Identifiers (`{{key:N}}`)](#template-identifiers-keyn)
83
+ - [Error Handling](#error-handling)
84
+ - [Configuration & API Reference](#configuration--api-reference)
85
+
86
+ ---
87
+
88
+ ## Installation
89
+
90
+ ```sh
91
+ npm install typebars
92
+ # or
93
+ yarn add typebars
94
+ # or
95
+ pnpm add typebars
96
+ # or
97
+ bun add typebars
98
+ ```
99
+
100
+ **Peer dependency:** TypeScript ≥ 5
101
+
102
+ ---
103
+
104
+ ## Quick Start
105
+
106
+ ```ts
107
+ import { Typebars } from "typebars";
108
+
109
+ const engine = new Typebars();
110
+
111
+ const schema = {
112
+ type: "object",
113
+ properties: {
114
+ name: { type: "string" },
115
+ age: { type: "number" },
116
+ },
117
+ required: ["name", "age"],
118
+ };
119
+
120
+ const data = { name: "Alice", age: 30 };
121
+
122
+ // 1. Analyze — validate + infer output type
123
+ const analysis = engine.analyze("Hello {{name}}", schema);
124
+ // analysis.valid → true
125
+ // analysis.outputSchema → { type: "string" }
126
+
127
+ // 2. Execute — render the template
128
+ const result = engine.execute("Hello {{name}}", data);
129
+ // result → "Hello Alice"
130
+
131
+ // 3. Or do both at once
132
+ const { analysis: a, value } = engine.analyzeAndExecute("{{age}}", schema, data);
133
+ // a.outputSchema → { type: "number" }
134
+ // value → 30
135
+ ```
136
+
137
+ ---
138
+
139
+ ## How It Works
140
+
141
+ Typebars operates in three phases:
142
+
143
+ ```
144
+ ┌────────────────────────────────────────────┐
145
+ │ Input Schema │
146
+ │ (JSON Schema describing available data) │
147
+ └──────────────────┬─────────────────────────┘
148
+
149
+ ┌──────────────┐ ┌───────────────────▼─────────────────────────┐
150
+ │ Template │───▶│ Static Analyzer │
151
+ │ (string) │ │ │
152
+ └──────────────┘ │ 1. Validates every {{expression}} against │
153
+ │ the input schema │
154
+ │ 2. Validates block helper usage (#if on │
155
+ │ existing property, #each on arrays...) │
156
+ │ 3. Infers the output JSON Schema from the │
157
+ │ template structure │
158
+ │ │
159
+ └──────┬───────────────────┬──────────────────┘
160
+ │ │
161
+ ┌────────────▼──┐ ┌──────────▼──────────┐
162
+ │ Diagnostics │ │ Output Schema │
163
+ │ (errors, │ │ (JSON Schema of │
164
+ │ warnings) │ │ the return value) │
165
+ └───────────────┘ └─────────────────────┘
166
+ ```
167
+
168
+ The **input schema** describes what variables are available. The **output schema** describes what the template will produce. The analyzer derives the output from the input — purely statically, without executing anything.
169
+
170
+ ---
171
+
172
+ ## Static Analysis — Input Validation
173
+
174
+ ### Property Validation
175
+
176
+ Every `{{expression}}` in the template is validated against the input schema. If a property doesn't exist, Typebars reports an error with the available properties:
177
+
178
+ ```ts
179
+ const schema = {
180
+ type: "object",
181
+ properties: {
182
+ name: { type: "string" },
183
+ age: { type: "number" },
184
+ },
185
+ };
186
+
187
+ // ✅ Valid — "name" exists in the schema
188
+ engine.analyze("{{name}}", schema);
189
+ // → { valid: true, diagnostics: [] }
190
+
191
+ // ❌ Invalid — "firstName" does not exist
192
+ engine.analyze("{{firstName}}", schema);
193
+ // → {
194
+ // valid: false,
195
+ // diagnostics: [{
196
+ // severity: "error",
197
+ // code: "UNKNOWN_PROPERTY",
198
+ // message: 'Property "firstName" does not exist in the context schema. Available properties: age, name',
199
+ // details: { path: "firstName", availableProperties: ["age", "name"] }
200
+ // }]
201
+ // }
202
+
203
+ // Multiple errors are reported at once
204
+ engine.analyze("{{foo}} and {{bar}}", schema);
205
+ // → 2 diagnostics, one for "foo" and one for "bar"
206
+ ```
207
+
208
+ ### Nested Properties (Dot Notation)
209
+
210
+ Dot notation is validated at every depth level:
211
+
212
+ ```ts
213
+ const schema = {
214
+ type: "object",
215
+ properties: {
216
+ address: {
217
+ type: "object",
218
+ properties: {
219
+ city: { type: "string" },
220
+ zip: { type: "string" },
221
+ },
222
+ },
223
+ metadata: {
224
+ type: "object",
225
+ properties: {
226
+ role: { type: "string", enum: ["admin", "user", "guest"] },
227
+ },
228
+ },
229
+ },
230
+ };
231
+
232
+ // ✅ Valid — full path resolved
233
+ engine.analyze("{{address.city}}", schema); // valid: true
234
+ engine.analyze("{{metadata.role}}", schema); // valid: true
235
+
236
+ // ❌ Invalid — "country" doesn't exist inside "address"
237
+ engine.analyze("{{address.country}}", schema);
238
+ // → error: Property "address.country" does not exist
239
+ ```
240
+
241
+ ### Validation Inside Block Helpers
242
+
243
+ The analyzer walks **into** block helpers and validates every expression in every branch:
244
+
245
+ ```ts
246
+ const schema = {
247
+ type: "object",
248
+ properties: {
249
+ active: { type: "boolean" },
250
+ name: { type: "string" },
251
+ tags: { type: "array", items: { type: "string" } },
252
+ orders: {
253
+ type: "array",
254
+ items: {
255
+ type: "object",
256
+ properties: {
257
+ id: { type: "number" },
258
+ product: { type: "string" },
259
+ },
260
+ },
261
+ },
262
+ address: {
263
+ type: "object",
264
+ properties: {
265
+ city: { type: "string" },
266
+ },
267
+ },
268
+ },
269
+ };
270
+
271
+ // ✅ #if — validates the condition AND both branches
272
+ engine.analyze("{{#if active}}{{name}}{{else}}unknown{{/if}}", schema);
273
+ // valid: true
274
+
275
+ // ❌ #if — invalid properties inside branches are caught
276
+ engine.analyze("{{#if active}}{{badProp1}}{{else}}{{badProp2}}{{/if}}", schema);
277
+ // valid: false — 2 errors (one per branch)
278
+
279
+ // ❌ #if — invalid condition is caught
280
+ engine.analyze("{{#if nonexistent}}yes{{/if}}", schema);
281
+ // valid: false — "nonexistent" doesn't exist
282
+
283
+ // ✅ #each — validates that the target is an array, then validates
284
+ // the body against the item schema
285
+ engine.analyze("{{#each orders}}{{product}} #{{id}}{{/each}}", schema);
286
+ // valid: true — "product" and "id" exist in the item schema
287
+
288
+ // ❌ #each — property doesn't exist in the item schema
289
+ engine.analyze("{{#each orders}}{{badField}}{{/each}}", schema);
290
+ // valid: false — "badField" doesn't exist in order items
291
+
292
+ // ❌ #each — target is not an array
293
+ engine.analyze("{{#each name}}{{this}}{{/each}}", schema);
294
+ // valid: false — TYPE_MISMATCH: "{{#each}}" expects array, got "string"
295
+
296
+ // ✅ #with — changes context to a sub-object, validates inner expressions
297
+ engine.analyze("{{#with address}}{{city}}{{/with}}", schema);
298
+ // valid: true
299
+
300
+ // ❌ #with — property doesn't exist in the sub-context
301
+ engine.analyze("{{#with address}}{{country}}{{/with}}", schema);
302
+ // valid: false — "country" doesn't exist inside "address"
303
+
304
+ // ✅ Nested blocks — validated at every level
305
+ engine.analyze(
306
+ "{{#with address}}{{city}}{{/with}} — {{#each tags}}{{this}}{{/each}}",
307
+ schema,
308
+ );
309
+ // valid: true
310
+ ```
311
+
312
+ **Key insight:** `{{#each}}` changes the schema context to the array's `items` schema. Inside `{{#each orders}}`, the context becomes `{ type: "object", properties: { id, product } }`. Inside `{{#each tags}}`, the context becomes `{ type: "string" }` and `{{this}}` refers to each string element.
313
+
314
+ `{{#with}}` changes the schema context to the resolved sub-object schema. Inside `{{#with address}}`, the context becomes the schema of `address`.
315
+
316
+ ### Diagnostics
317
+
318
+ Every diagnostic is a structured object with machine-readable fields:
319
+
320
+ ```ts
321
+ interface TemplateDiagnostic {
322
+ severity: "error" | "warning";
323
+ code: DiagnosticCode;
324
+ message: string;
325
+ loc?: {
326
+ start: { line: number; column: number };
327
+ end: { line: number; column: number };
328
+ };
329
+ source?: string;
330
+ details?: {
331
+ path?: string;
332
+ helperName?: string;
333
+ expected?: string;
334
+ actual?: string;
335
+ availableProperties?: string[];
336
+ identifier?: number;
337
+ };
338
+ }
339
+ ```
340
+
341
+ Available diagnostic codes:
342
+
343
+ | Code | Severity | Description |
344
+ |------|----------|-------------|
345
+ | `UNKNOWN_PROPERTY` | error | Property doesn't exist in the schema |
346
+ | `TYPE_MISMATCH` | error | Incompatible type (e.g. `{{#each}}` on a non-array) |
347
+ | `MISSING_ARGUMENT` | error | Block helper used without required argument |
348
+ | `UNKNOWN_HELPER` | warning | Unknown block helper |
349
+ | `UNANALYZABLE` | warning | Expression can't be statically analyzed |
350
+ | `MISSING_IDENTIFIER_SCHEMAS` | error | `{{key:N}}` used but no identifier schemas provided |
351
+ | `UNKNOWN_IDENTIFIER` | error | Identifier N not found in identifier schemas |
352
+ | `IDENTIFIER_PROPERTY_NOT_FOUND` | error | Property doesn't exist in identifier's schema |
353
+ | `PARSE_ERROR` | error | Invalid Handlebars syntax |
354
+
355
+ ---
356
+
357
+ ## Static Analysis — Output Schema Inference
358
+
359
+ This is where Typebars shines. Given a template and an input schema, Typebars **infers the JSON Schema of the output value**. The inferred type depends on the template structure.
360
+
361
+ ### Single Expression → Resolved Type
362
+
363
+ When the template is a single `{{expression}}` (with optional whitespace around it), the output schema is the resolved type from the input schema:
364
+
365
+ ```ts
366
+ const schema = {
367
+ type: "object",
368
+ properties: {
369
+ name: { type: "string" },
370
+ age: { type: "number" },
371
+ score: { type: "integer" },
372
+ active: { type: "boolean" },
373
+ address: {
374
+ type: "object",
375
+ properties: {
376
+ city: { type: "string" },
377
+ zip: { type: "string" },
378
+ },
379
+ },
380
+ tags: {
381
+ type: "array",
382
+ items: { type: "string" },
383
+ },
384
+ role: { type: "string", enum: ["admin", "user", "guest"] },
385
+ },
386
+ };
387
+
388
+ engine.analyze("{{name}}", schema).outputSchema;
389
+ // → { type: "string" }
390
+
391
+ engine.analyze("{{age}}", schema).outputSchema;
392
+ // → { type: "number" }
393
+
394
+ engine.analyze("{{score}}", schema).outputSchema;
395
+ // → { type: "integer" }
396
+
397
+ engine.analyze("{{active}}", schema).outputSchema;
398
+ // → { type: "boolean" }
399
+
400
+ engine.analyze("{{address}}", schema).outputSchema;
401
+ // → { type: "object", properties: { city: { type: "string" }, zip: { type: "string" } } }
402
+
403
+ engine.analyze("{{tags}}", schema).outputSchema;
404
+ // → { type: "array", items: { type: "string" } }
405
+
406
+ // Dot notation resolves to the leaf type
407
+ engine.analyze("{{address.city}}", schema).outputSchema;
408
+ // → { type: "string" }
409
+
410
+ // Enums are preserved
411
+ engine.analyze("{{role}}", schema).outputSchema;
412
+ // → { type: "string", enum: ["admin", "user", "guest"] }
413
+
414
+ // Whitespace around a single expression is ignored
415
+ engine.analyze(" {{age}} ", schema).outputSchema;
416
+ // → { type: "number" }
417
+ ```
418
+
419
+ **This is the key mechanism**: the output schema is **derived from** the input schema by resolving the expression path. `{{age}}` in a schema where `age` is `{ type: "number" }` produces an output schema of `{ type: "number" }`.
420
+
421
+ ### Mixed Template → String
422
+
423
+ When a template contains text **and** expressions, or multiple expressions, the output is always `{ type: "string" }` — because Handlebars concatenates everything into a string:
424
+
425
+ ```ts
426
+ engine.analyze("Hello {{name}}", schema).outputSchema;
427
+ // → { type: "string" }
428
+
429
+ engine.analyze("{{name}} ({{age}})", schema).outputSchema;
430
+ // → { type: "string" }
431
+
432
+ engine.analyze("Just plain text", schema).outputSchema;
433
+ // → { type: "string" }
434
+ ```
435
+
436
+ ### Single Block → Branch Type Inference
437
+
438
+ When the template is a **single block** (optionally surrounded by whitespace), Typebars infers the type from the block's branches:
439
+
440
+ ```ts
441
+ // Both branches are numeric literals → output is number
442
+ engine.analyze("{{#if active}}10{{else}}20{{/if}}", schema).outputSchema;
443
+ // → { type: "number" }
444
+
445
+ // Both branches are booleans → output is boolean
446
+ engine.analyze("{{#if active}}true{{else}}false{{/if}}", schema).outputSchema;
447
+ // → { type: "boolean" }
448
+
449
+ // Both branches are single expressions of the same type → that type
450
+ engine.analyze(
451
+ "{{#if active}}{{name}}{{else}}{{address.city}}{{/if}}",
452
+ schema,
453
+ ).outputSchema;
454
+ // → { type: "string" }
455
+ // (both are string, so the output is string)
456
+
457
+ // Branches with different types → oneOf union
458
+ engine.analyze(
459
+ "{{#if active}}{{age}}{{else}}{{score}}{{/if}}",
460
+ schema,
461
+ ).outputSchema;
462
+ // → { oneOf: [{ type: "number" }, { type: "integer" }] }
463
+
464
+ engine.analyze(
465
+ "{{#if active}}42{{else}}hello{{/if}}",
466
+ schema,
467
+ ).outputSchema;
468
+ // → { oneOf: [{ type: "number" }, { type: "string" }] }
469
+
470
+ // null in one branch → union with null
471
+ engine.analyze(
472
+ "{{#if active}}null{{else}}fallback{{/if}}",
473
+ schema,
474
+ ).outputSchema;
475
+ // → { oneOf: [{ type: "null" }, { type: "string" }] }
476
+
477
+ // #unless works the same way
478
+ engine.analyze(
479
+ "{{#unless active}}0{{else}}1{{/unless}}",
480
+ schema,
481
+ ).outputSchema;
482
+ // → { type: "number" }
483
+
484
+ // #with as single block → type of the inner body
485
+ engine.analyze("{{#with address}}{{city}}{{/with}}", schema).outputSchema;
486
+ // → { type: "string" }
487
+
488
+ // #each always produces string (concatenation of iterations)
489
+ engine.analyze("{{#each tags}}{{this}}{{/each}}", schema).outputSchema;
490
+ // → { type: "string" }
491
+ ```
492
+
493
+ The inference logic per block type:
494
+
495
+ | Block | Output Schema |
496
+ |-------|---------------|
497
+ | `{{#if}}` with else | `oneOf(then_type, else_type)` (simplified if both are equal) |
498
+ | `{{#if}}` without else | Type of the then branch |
499
+ | `{{#unless}}` | Same as `{{#if}}` (inverted semantics, same type inference) |
500
+ | `{{#each}}` | Always `{ type: "string" }` (concatenation) |
501
+ | `{{#with}}` | Type of the inner body |
502
+
503
+ ### Object Templates → Object Schema
504
+
505
+ When you pass an object as a template, each property is analyzed independently and the output schema is an object schema:
506
+
507
+ ```ts
508
+ const schema = {
509
+ type: "object",
510
+ properties: {
511
+ name: { type: "string" },
512
+ age: { type: "number" },
513
+ city: { type: "string" },
514
+ },
515
+ };
516
+
517
+ const analysis = engine.analyze(
518
+ {
519
+ userName: "Hello {{name}}!", // mixed → string
520
+ userAge: "{{age}}", // single expression → number
521
+ location: "{{city}}", // single expression → string
522
+ },
523
+ schema,
524
+ );
525
+
526
+ analysis.outputSchema;
527
+ // → {
528
+ // type: "object",
529
+ // properties: {
530
+ // userName: { type: "string" },
531
+ // userAge: { type: "number" },
532
+ // location: { type: "string" },
533
+ // },
534
+ // required: ["userName", "userAge", "location"],
535
+ // }
536
+ ```
537
+
538
+ Nesting works recursively:
539
+
540
+ ```ts
541
+ engine.analyze(
542
+ {
543
+ user: {
544
+ name: "{{name}}",
545
+ age: "{{age}}",
546
+ },
547
+ meta: {
548
+ active: "{{active}}",
549
+ },
550
+ },
551
+ schema,
552
+ ).outputSchema;
553
+ // → {
554
+ // type: "object",
555
+ // properties: {
556
+ // user: {
557
+ // type: "object",
558
+ // properties: {
559
+ // name: { type: "string" },
560
+ // age: { type: "number" },
561
+ // },
562
+ // required: ["name", "age"],
563
+ // },
564
+ // meta: {
565
+ // type: "object",
566
+ // properties: {
567
+ // active: { type: "boolean" },
568
+ // },
569
+ // required: ["active"],
570
+ // },
571
+ // },
572
+ // required: ["user", "meta"],
573
+ // }
574
+ ```
575
+
576
+ If **any** property in the object template is invalid, the entire object is marked as `valid: false` and all diagnostics are collected.
577
+
578
+ ### Literal Inputs → Primitive Schema
579
+
580
+ Non-string values (`number`, `boolean`, `null`) are treated as passthrough literals. They are always valid (the input schema is ignored) and their output schema is inferred from the value:
581
+
582
+ ```ts
583
+ engine.analyze(42, schema).outputSchema;
584
+ // → { type: "integer" }
585
+
586
+ engine.analyze(3.14, schema).outputSchema;
587
+ // → { type: "number" }
588
+
589
+ engine.analyze(true, schema).outputSchema;
590
+ // → { type: "boolean" }
591
+
592
+ engine.analyze(null, schema).outputSchema;
593
+ // → { type: "null" }
594
+ ```
595
+
596
+ This is useful in object templates where some properties are fixed values:
597
+
598
+ ```ts
599
+ engine.analyze(
600
+ {
601
+ name: "{{name}}", // → string (from schema)
602
+ version: 42, // → integer (literal)
603
+ isPublic: true, // → boolean (literal)
604
+ deleted: null, // → null (literal)
605
+ },
606
+ schema,
607
+ ).outputSchema;
608
+ // → {
609
+ // type: "object",
610
+ // properties: {
611
+ // name: { type: "string" },
612
+ // version: { type: "integer" },
613
+ // isPublic: { type: "boolean" },
614
+ // deleted: { type: "null" },
615
+ // },
616
+ // required: ["name", "version", "isPublic", "deleted"],
617
+ // }
618
+ ```
619
+
620
+ ---
621
+
622
+ ## Schema Features
623
+
624
+ ### `$ref` Resolution
625
+
626
+ Internal `$ref` references (`#/definitions/...`) are resolved transparently:
627
+
628
+ ```ts
629
+ const schema = {
630
+ type: "object",
631
+ definitions: {
632
+ Address: {
633
+ type: "object",
634
+ properties: {
635
+ street: { type: "string" },
636
+ city: { type: "string" },
637
+ },
638
+ },
639
+ },
640
+ properties: {
641
+ home: { $ref: "#/definitions/Address" },
642
+ work: { $ref: "#/definitions/Address" },
643
+ },
644
+ };
645
+
646
+ // ✅ Resolves through $ref
647
+ engine.analyze("{{home.city}}", schema);
648
+
649
+ // → valid: true, outputSchema: { type: "string" }
650
+
651
+ // ✅ Works with multiple $ref to the same definition
652
+ engine.analyze("{{home.city}} — {{work.street}}", schema);
653
+ // → valid: true
654
+
655
+ // ❌ Property doesn't exist behind the $ref
656
+ engine.analyze("{{home.zip}}", schema);
657
+ // → valid: false — "zip" not found in Address
658
+ ```
659
+
660
+ Nested `$ref` (a `$ref` pointing to another `$ref`) is resolved recursively.
661
+
662
+ ### Combinators (`allOf`, `anyOf`, `oneOf`)
663
+
664
+ Properties defined across combinators are resolved:
665
+
666
+ ```ts
667
+ const schema = {
668
+ type: "object",
669
+ allOf: [
670
+ { type: "object", properties: { a: { type: "string" } } },
671
+ { type: "object", properties: { b: { type: "number" } } },
672
+ ],
673
+ };
674
+
675
+ engine.analyze("{{a}}", schema).valid; // true → { type: "string" }
676
+ engine.analyze("{{b}}", schema).valid; // true → { type: "number" }
677
+ ```
678
+
679
+ `oneOf` and `anyOf` branches are also searched.
680
+
681
+ ### `additionalProperties`
682
+
683
+ When a property isn't found in `properties` but `additionalProperties` is set:
684
+
685
+ ```ts
686
+ // additionalProperties: true → any property is allowed (type unknown)
687
+ engine.analyze("{{anything}}", { type: "object", additionalProperties: true });
688
+ // → valid: true, outputSchema: {}
689
+
690
+ // additionalProperties with a schema → resolved to that schema
691
+ engine.analyze("{{anything}}", {
692
+ type: "object",
693
+ additionalProperties: { type: "number" },
694
+ });
695
+ // → valid: true, outputSchema: { type: "number" }
696
+
697
+ // additionalProperties: false → unknown properties are rejected
698
+ engine.analyze("{{anything}}", {
699
+ type: "object",
700
+ properties: { name: { type: "string" } },
701
+ additionalProperties: false,
702
+ });
703
+ // → valid: false
704
+ ```
705
+
706
+ ### Array `.length` Intrinsic
707
+
708
+ Accessing `.length` on an array is valid and inferred as `{ type: "integer" }`:
709
+
710
+ ```ts
711
+ const schema = {
712
+ type: "object",
713
+ properties: {
714
+ tags: { type: "array", items: { type: "string" } },
715
+ orders: { type: "array", items: { type: "object", properties: { id: { type: "number" } } } },
716
+ },
717
+ };
718
+
719
+ engine.analyze("{{tags.length}}", schema).outputSchema;
720
+ // → { type: "integer" }
721
+
722
+ engine.analyze("{{orders.length}}", schema).outputSchema;
723
+ // → { type: "integer" }
724
+
725
+ // .length on a non-array is invalid
726
+ engine.analyze("{{name.length}}", {
727
+ type: "object",
728
+ properties: { name: { type: "string" } },
729
+ });
730
+ // → valid: false, code: UNKNOWN_PROPERTY
731
+ ```
732
+
733
+ ---
734
+
735
+ ## Execution & Type Preservation
736
+
737
+ Typebars preserves types at execution time. The behavior depends on the template structure:
738
+
739
+ | Template Shape | Execution Return Type |
740
+ |---|---|
741
+ | Single expression `{{expr}}` | Raw value (`number`, `boolean`, `object`, `array`, `null`…) |
742
+ | Mixed template `text {{expr}}` | `string` (concatenation) |
743
+ | Single block with literal branches | Coerced value (`number`, `boolean`, `null`) |
744
+ | Multi-block or mixed | `string` |
745
+ | Literal input (`42`, `true`, `null`) | The value as-is |
746
+
747
+ ```ts
748
+ const engine = new Typebars();
749
+ const data = { name: "Alice", age: 30, active: true, tags: ["ts", "js"] };
750
+
751
+ // Single expression → raw type
752
+ engine.execute("{{age}}", data); // → 30 (number)
753
+ engine.execute("{{active}}", data); // → true (boolean)
754
+ engine.execute("{{tags}}", data); // → ["ts", "js"] (array)
755
+
756
+ // Mixed → string
757
+ engine.execute("Age: {{age}}", data); // → "Age: 30" (string)
758
+
759
+ // Single block with literal branches → coerced
760
+ engine.execute("{{#if active}}42{{else}}0{{/if}}", data); // → 42 (number)
761
+ engine.execute("{{#if active}}true{{else}}false{{/if}}", data); // → true (boolean)
762
+
763
+ // Literal passthrough
764
+ engine.execute(42, data); // → 42
765
+ engine.execute(true, data); // → true
766
+ engine.execute(null, data); // → null
767
+ ```
768
+
769
+ This means the **output schema** inferred at analysis time matches the actual runtime value type.
770
+
771
+ ---
772
+
773
+ ## Compiled Templates
774
+
775
+ For templates executed multiple times, `compile()` parses the template once and returns a reusable `CompiledTemplate`:
776
+
777
+ ```ts
778
+ const engine = new Typebars();
779
+ const tpl = engine.compile("Hello {{name}}!");
780
+
781
+ // No re-parsing — execute many times
782
+ tpl.execute({ name: "Alice" }); // → "Hello Alice!"
783
+ tpl.execute({ name: "Bob" }); // → "Hello Bob!"
784
+
785
+ // Analyze without re-parsing
786
+ tpl.analyze(schema);
787
+
788
+ // Validate
789
+ tpl.validate(schema);
790
+
791
+ // Both at once
792
+ const { analysis, value } = tpl.analyzeAndExecute(schema, data);
793
+ ```
794
+
795
+ Object templates and literal values can also be compiled:
796
+
797
+ ```ts
798
+ const tpl = engine.compile({
799
+ userName: "{{name}}",
800
+ userAge: "{{age}}",
801
+ fixed: 42,
802
+ });
803
+
804
+ tpl.execute(data);
805
+ // → { userName: "Alice", userAge: 30, fixed: 42 }
806
+
807
+ tpl.analyze(schema).outputSchema;
808
+ // → { type: "object", properties: { userName: { type: "string" }, userAge: { type: "number" }, fixed: { type: "integer" } }, ... }
809
+ ```
810
+
811
+ ---
812
+
813
+ ## Object Templates
814
+
815
+ Pass an object where each property is a template. Every property is analyzed/executed independently:
816
+
817
+ ```ts
818
+ const engine = new Typebars();
819
+
820
+ // Execute
821
+ const result = engine.execute(
822
+ {
823
+ greeting: "Hello {{name}}!",
824
+ userAge: "{{age}}",
825
+ tags: "{{tags}}",
826
+ fixed: 42,
827
+ nested: {
828
+ city: "{{address.city}}",
829
+ },
830
+ },
831
+ data,
832
+ );
833
+ // → {
834
+ // greeting: "Hello Alice!",
835
+ // userAge: 30,
836
+ // tags: ["ts", "js"],
837
+ // fixed: 42,
838
+ // nested: { city: "Paris" },
839
+ // }
840
+
841
+ // Validate — errors from ALL properties are collected
842
+ const analysis = engine.analyze(
843
+ {
844
+ ok: "{{name}}",
845
+ bad: "{{nonexistent}}",
846
+ },
847
+ schema,
848
+ );
849
+ // analysis.valid → false (at least one property has an error)
850
+ // analysis.diagnostics → [{ ... "nonexistent" ... }]
851
+ ```
852
+
853
+ ---
854
+
855
+ ## Block Helpers
856
+
857
+ ### `{{#if}}` / `{{#unless}}`
858
+
859
+ Conditional rendering. The analyzer validates the condition and both branches.
860
+
861
+ The condition can be a simple property reference **or a sub-expression** using one of the [built-in logical helpers](#built-in-logical--comparison-helpers):
862
+
863
+ ```ts
864
+ // Simple property condition
865
+ engine.execute("{{#if active}}Online{{else}}Offline{{/if}}", data);
866
+ // → "Online"
867
+
868
+ engine.execute("{{#unless active}}No{{else}}Yes{{/unless}}", { active: false });
869
+ // → "No"
870
+
871
+ // Sub-expression condition (see "Built-in Logical & Comparison Helpers")
872
+ engine.execute(
873
+ "{{#if (gt age 18)}}Adult{{else}}Minor{{/if}}",
874
+ { age: 30 },
875
+ );
876
+ // → "Adult"
877
+
878
+ engine.execute(
879
+ '{{#if (eq role "admin")}}Full access{{else}}Limited{{/if}}',
880
+ { role: "admin" },
881
+ );
882
+ // → "Full access"
883
+ ```
884
+
885
+ > **Note:** Handlebars natively only supports simple property references as `{{#if}}` conditions (e.g. `{{#if active}}`). Sub-expression conditions like `{{#if (gt age 18)}}` are made possible by the logical helpers that Typebars pre-registers on every engine instance. See [Built-in Logical & Comparison Helpers](#built-in-logical--comparison-helpers) for details.
886
+
887
+ ### `{{#each}}`
888
+
889
+ Iterates over arrays. The analyzer validates that the target is an array and switches the schema context to the item schema:
890
+
891
+ ```ts
892
+ engine.execute("{{#each tags}}{{this}} {{/each}}", data);
893
+ // → "ts js "
894
+
895
+ engine.execute("{{#each orders}}#{{id}} {{product}} {{/each}}", {
896
+ orders: [
897
+ { id: 1, product: "Keyboard" },
898
+ { id: 2, product: "Mouse" },
899
+ ],
900
+ });
901
+ // → "#1 Keyboard #2 Mouse "
902
+
903
+ // Nested #each
904
+ engine.execute(
905
+ "{{#each groups}}[{{#each members}}{{this}}{{/each}}]{{/each}}",
906
+ { groups: [{ members: ["a", "b"] }, { members: ["c"] }] },
907
+ );
908
+ // → "[ab][c]"
909
+ ```
910
+
911
+ ### `{{#with}}`
912
+
913
+ Changes the context to a sub-object. The analyzer switches the schema context:
914
+
915
+ ```ts
916
+ engine.execute("{{#with address}}{{city}}, {{zip}}{{/with}}", {
917
+ address: { city: "Paris", zip: "75001" },
918
+ });
919
+ // → "Paris, 75001"
920
+
921
+ // Nested #with
922
+ engine.analyze(
923
+ "{{#with level1}}{{#with level2}}{{value}}{{/with}}{{/with}}",
924
+ {
925
+ type: "object",
926
+ properties: {
927
+ level1: {
928
+ type: "object",
929
+ properties: {
930
+ level2: {
931
+ type: "object",
932
+ properties: { value: { type: "string" } },
933
+ },
934
+ },
935
+ },
936
+ },
937
+ },
938
+ );
939
+ // → valid: true
940
+ ```
941
+
942
+ ---
943
+
944
+ ## Built-in Math Helpers
945
+
946
+ Pre-registered on every `Typebars` instance. All return `{ type: "number" }` for static analysis.
947
+
948
+ ### Named Operators
949
+
950
+ | Helper | Aliases | Usage | Example |
951
+ |--------|---------|-------|---------|
952
+ | `add` | — | `{{add a b}}` | `{{add price tax}}` → 121 |
953
+ | `subtract` | `sub` | `{{sub a b}}` | `{{sub price discount}}` → 80 |
954
+ | `multiply` | `mul` | `{{mul a b}}` | `{{mul price quantity}}` → 300 |
955
+ | `divide` | `div` | `{{div a b}}` | `{{div total count}}` → 33.3 |
956
+ | `modulo` | `mod` | `{{mod a b}}` | `{{mod 10 3}}` → 1 |
957
+ | `pow` | — | `{{pow a b}}` | `{{pow 2 10}}` → 1024 |
958
+ | `min` | — | `{{min a b}}` | `{{min a b}}` → smaller |
959
+ | `max` | — | `{{max a b}}` | `{{max a b}}` → larger |
960
+
961
+ ### Unary Functions
962
+
963
+ | Helper | Usage | Description |
964
+ |--------|-------|-------------|
965
+ | `abs` | `{{abs value}}` | Absolute value |
966
+ | `ceil` | `{{ceil value}}` | Round up |
967
+ | `floor` | `{{floor value}}` | Round down |
968
+ | `round` | `{{round value [precision]}}` | Round with optional decimal places |
969
+ | `sqrt` | `{{sqrt value}}` | Square root |
970
+
971
+ ```ts
972
+ engine.execute("{{round pi 2}}", { pi: 3.14159 }); // → 3.14
973
+ engine.execute("{{abs value}}", { value: -7 }); // → 7
974
+ engine.execute("{{div items.length count}}", { items: [1,2,3,4,5], count: 2 }); // → 2.5
975
+ ```
976
+
977
+ ### Generic `math` Helper
978
+
979
+ Inline arithmetic with the operator as a string:
980
+
981
+ ```ts
982
+ engine.execute('{{math a "+" b}}', { a: 10, b: 3 }); // → 13
983
+ engine.execute('{{math a "*" b}}', { a: 10, b: 3 }); // → 30
984
+ engine.execute('{{math a "**" b}}', { a: 2, b: 10 }); // → 1024
985
+ ```
986
+
987
+ Supported operators: `+`, `-`, `*`, `/`, `%`, `**`
988
+
989
+ All math helpers are **fully integrated with static analysis** — they validate that parameters resolve to `{ type: "number" }` and infer `{ type: "number" }` as output:
990
+
991
+ ```ts
992
+ const schema = {
993
+ type: "object",
994
+ properties: {
995
+ a: { type: "number" },
996
+ b: { type: "number" },
997
+ label: { type: "string" },
998
+ },
999
+ };
1000
+
1001
+ // ✅ Valid
1002
+ const { analysis, value } = engine.analyzeAndExecute(
1003
+ "{{add a b}}",
1004
+ schema,
1005
+ { a: 10, b: 3 },
1006
+ );
1007
+ analysis.outputSchema; // → { type: "number" }
1008
+ value; // → 13
1009
+
1010
+ // ⚠️ Type mismatch on parameter
1011
+ const { analysis: a2 } = engine.analyzeAndExecute(
1012
+ "{{add a label}}",
1013
+ schema,
1014
+ { a: 10, label: "hello" },
1015
+ );
1016
+ // a2.valid → false (string passed to a number parameter)
1017
+ ```
1018
+
1019
+ ---
1020
+
1021
+ ## Built-in Logical & Comparison Helpers
1022
+
1023
+ Pre-registered on every `Typebars` instance. All return `{ type: "boolean" }` for static analysis.
1024
+
1025
+ These helpers enable **conditional logic** inside templates via Handlebars [sub-expressions](https://handlebarsjs.com/guide/subexpressions.html) — the `(helper arg1 arg2)` syntax used as arguments to block helpers like `{{#if}}` and `{{#unless}}`.
1026
+
1027
+ ### Why Sub-Expressions?
1028
+
1029
+ Standard Handlebars only supports simple truthiness checks:
1030
+
1031
+ ```handlebars
1032
+ {{!-- Native Handlebars: can only test if "active" is truthy --}}
1033
+ {{#if active}}yes{{else}}no{{/if}}
1034
+ ```
1035
+
1036
+ There is **no built-in way** to compare values, combine conditions, or negate expressions. Handlebars deliberately delegates this to helpers.
1037
+
1038
+ Typebars ships a complete set of logical and comparison helpers that unlock expressive conditions out of the box:
1039
+
1040
+ ```handlebars
1041
+ {{!-- Typebars: compare, combine, negate — all statically analyzed --}}
1042
+ {{#if (gt age 18)}}adult{{else}}minor{{/if}}
1043
+ {{#if (and active (eq role "admin"))}}full access{{/if}}
1044
+ {{#if (not suspended)}}welcome{{/if}}
1045
+ ```
1046
+
1047
+ These helpers are **fully integrated with the static analyzer**: argument types are validated, missing properties are caught, and the output schema is correctly inferred from the branches — not from the boolean condition.
1048
+
1049
+ ### Comparison Helpers
1050
+
1051
+ | Helper | Aliases | Usage | Description |
1052
+ |--------|---------|-------|-------------|
1053
+ | `lt` | — | `(lt a b)` | `a < b` (numeric) |
1054
+ | `lte` | `le` | `(lte a b)` | `a <= b` (numeric) |
1055
+ | `gt` | — | `(gt a b)` | `a > b` (numeric) |
1056
+ | `gte` | `ge` | `(gte a b)` | `a >= b` (numeric) |
1057
+
1058
+ Both parameters must resolve to `{ type: "number" }`. A type mismatch is reported as an error:
1059
+
1060
+ ```ts
1061
+ const schema = {
1062
+ type: "object",
1063
+ properties: {
1064
+ age: { type: "number" },
1065
+ score: { type: "number" },
1066
+ name: { type: "string" },
1067
+ account: {
1068
+ type: "object",
1069
+ properties: { balance: { type: "number" } },
1070
+ },
1071
+ },
1072
+ };
1073
+
1074
+ // ✅ Both arguments are numbers
1075
+ engine.analyze("{{#if (lt age 18)}}minor{{else}}adult{{/if}}", schema);
1076
+ // valid: true, no diagnostics
1077
+
1078
+ // ✅ Nested property access works
1079
+ engine.analyze("{{#if (lt account.balance 500)}}low{{else}}ok{{/if}}", schema);
1080
+ // valid: true
1081
+
1082
+ // ✅ Number literals are accepted
1083
+ engine.analyze("{{#if (gte score 90)}}A{{else}}B{{/if}}", schema);
1084
+ // valid: true
1085
+
1086
+ // ❌ String where number is expected → TYPE_MISMATCH
1087
+ engine.analyze("{{#if (lt name 500)}}yes{{/if}}", schema);
1088
+ // valid: false — "name" is string, "lt" expects number
1089
+ ```
1090
+
1091
+ ### Equality Helpers
1092
+
1093
+ | Helper | Aliases | Usage | Description |
1094
+ |--------|---------|-------|-------------|
1095
+ | `eq` | — | `(eq a b)` | Strict equality (`===`) |
1096
+ | `ne` | `neq` | `(ne a b)` | Strict inequality (`!==`) |
1097
+
1098
+ These accept any type — no type constraint on parameters:
1099
+
1100
+ ```ts
1101
+ // String comparison
1102
+ engine.execute('{{#if (eq role "admin")}}yes{{else}}no{{/if}}', { role: "admin" });
1103
+ // → "yes"
1104
+
1105
+ // Number comparison
1106
+ engine.execute("{{#if (ne score 0)}}scored{{else}}zero{{/if}}", { score: 85 });
1107
+ // → "scored"
1108
+ ```
1109
+
1110
+ ### Logical Operators
1111
+
1112
+ | Helper | Usage | Description |
1113
+ |--------|-------|-------------|
1114
+ | `not` | `(not value)` | Logical negation — `true` if value is falsy |
1115
+ | `and` | `(and a b)` | Logical AND — `true` if both are truthy |
1116
+ | `or` | `(or a b)` | Logical OR — `true` if at least one is truthy |
1117
+
1118
+ ```ts
1119
+ engine.execute("{{#if (not active)}}inactive{{else}}active{{/if}}", { active: false });
1120
+ // → "inactive"
1121
+
1122
+ engine.execute(
1123
+ "{{#if (and active premium)}}VIP{{else}}standard{{/if}}",
1124
+ { active: true, premium: true },
1125
+ );
1126
+ // → "VIP"
1127
+
1128
+ engine.execute(
1129
+ "{{#if (or isAdmin isModerator)}}staff{{else}}user{{/if}}",
1130
+ { isAdmin: false, isModerator: true },
1131
+ );
1132
+ // → "staff"
1133
+ ```
1134
+
1135
+ ### Collection Helpers
1136
+
1137
+ | Helper | Usage | Description |
1138
+ |--------|-------|-------------|
1139
+ | `contains` | `(contains haystack needle)` | `true` if the string contains the substring, or the array contains the element |
1140
+ | `in` | `(in value ...candidates)` | `true` if value is one of the candidates (variadic) |
1141
+
1142
+ ```ts
1143
+ engine.execute(
1144
+ '{{#if (contains name "ali")}}match{{else}}no match{{/if}}',
1145
+ { name: "Alice" },
1146
+ );
1147
+ // → "match"
1148
+
1149
+ engine.execute(
1150
+ '{{#if (in status "active" "pending")}}ok{{else}}blocked{{/if}}',
1151
+ { status: "active" },
1152
+ );
1153
+ // → "ok"
1154
+ ```
1155
+
1156
+ ### Generic `compare` Helper
1157
+
1158
+ A single helper with the operator as a string parameter:
1159
+
1160
+ ```ts
1161
+ engine.execute('{{#if (compare a "<" b)}}yes{{else}}no{{/if}}', { a: 3, b: 10 });
1162
+ // → "yes"
1163
+
1164
+ engine.execute('{{#if (compare name "===" "Alice")}}hi Alice{{/if}}', { name: "Alice" });
1165
+ // → "hi Alice"
1166
+ ```
1167
+
1168
+ Supported operators: `==`, `===`, `!=`, `!==`, `<`, `<=`, `>`, `>=`
1169
+
1170
+ ### Nested Sub-Expressions
1171
+
1172
+ Sub-expressions can be **nested** to build complex conditions. Each level is fully analyzed:
1173
+
1174
+ ```ts
1175
+ // AND + comparison
1176
+ engine.execute(
1177
+ '{{#if (and (eq role "admin") (gt score 90))}}top admin{{else}}other{{/if}}',
1178
+ { role: "admin", score: 95 },
1179
+ );
1180
+ // → "top admin"
1181
+
1182
+ // OR + NOT
1183
+ engine.execute(
1184
+ "{{#if (or (not active) (lt score 10))}}alert{{else}}ok{{/if}}",
1185
+ { active: true, score: 85 },
1186
+ );
1187
+ // → "ok"
1188
+
1189
+ // Deeply nested
1190
+ engine.execute(
1191
+ '{{#if (and (or (lt age 18) (gt age 65)) (eq role "special"))}}discount{{else}}full price{{/if}}',
1192
+ { age: 70, role: "special" },
1193
+ );
1194
+ // → "discount"
1195
+ ```
1196
+
1197
+ ### Static Analysis of Sub-Expressions
1198
+
1199
+ Sub-expressions are **fully integrated** with the static analyzer. The key behaviors:
1200
+
1201
+ **1. Argument validation** — every argument is resolved against the schema:
1202
+
1203
+ ```ts
1204
+ // ❌ Unknown property in sub-expression argument
1205
+ engine.analyze("{{#if (lt nonExistent 500)}}yes{{/if}}", schema);
1206
+ // valid: false — UNKNOWN_PROPERTY
1207
+
1208
+ // ❌ Missing nested property
1209
+ engine.analyze("{{#if (lt account.foo 500)}}yes{{/if}}", schema);
1210
+ // valid: false — UNKNOWN_PROPERTY
1211
+
1212
+ // ❌ Too few arguments
1213
+ engine.analyze("{{#if (lt age)}}yes{{/if}}", schema);
1214
+ // valid: false — MISSING_ARGUMENT
1215
+ ```
1216
+
1217
+ **2. Type checking** — parameter types are validated against helper declarations:
1218
+
1219
+ ```ts
1220
+ // ❌ String where number is expected
1221
+ engine.analyze("{{#if (lt name 500)}}yes{{/if}}", schema);
1222
+ // valid: false — TYPE_MISMATCH: "lt" parameter "a" expects number, got string
1223
+ ```
1224
+
1225
+ **3. Output type inference** — the output schema is based on the **branches**, not the condition:
1226
+
1227
+ ```ts
1228
+ // The condition (lt ...) returns boolean, but the output type
1229
+ // comes from the branch content:
1230
+
1231
+ engine.analyze("{{#if (lt age 18)}}{{name}}{{else}}{{age}}{{/if}}", schema).outputSchema;
1232
+ // → { oneOf: [{ type: "string" }, { type: "number" }] }
1233
+ // (NOT boolean — the condition type doesn't leak into the output)
1234
+
1235
+ engine.analyze("{{#if (gt score 50)}}{{age}}{{else}}{{score}}{{/if}}", schema).outputSchema;
1236
+ // → { type: "number" }
1237
+ // (both branches are number → simplified to single type)
1238
+
1239
+ engine.analyze("{{#if (eq age 18)}}42{{else}}true{{/if}}", schema).outputSchema;
1240
+ // → { oneOf: [{ type: "number" }, { type: "boolean" }] }
1241
+
1242
+ // Chained else-if pattern
1243
+ engine.analyze(
1244
+ "{{#if (lt age 18)}}minor{{else}}{{#if (lt age 65)}}adult{{else}}senior{{/if}}{{/if}}",
1245
+ schema,
1246
+ ).outputSchema;
1247
+ // → { type: "string" }
1248
+ // (all branches are string literals → simplified)
1249
+ ```
1250
+
1251
+ **4. Unknown helpers** — unregistered helpers emit a warning (not an error):
1252
+
1253
+ ```ts
1254
+ engine.analyze("{{#if (myCustomCheck age)}}yes{{/if}}", schema);
1255
+ // valid: true, but 1 warning: UNKNOWN_HELPER "myCustomCheck"
1256
+ ```
1257
+
1258
+ ---
1259
+
1260
+ ## Custom Helpers
1261
+
1262
+ ### `registerHelper`
1263
+
1264
+ Register a custom helper with type metadata for static analysis:
1265
+
1266
+ ```ts
1267
+ const engine = new Typebars();
1268
+
1269
+ engine.registerHelper("uppercase", {
1270
+ fn: (value) => String(value).toUpperCase(),
1271
+ params: [
1272
+ { name: "value", type: { type: "string" }, description: "The string to convert" },
1273
+ ],
1274
+ returnType: { type: "string" },
1275
+ description: "Converts to UPPERCASE",
1276
+ });
1277
+
1278
+ // Execution
1279
+ engine.execute("{{uppercase name}}", { name: "alice" });
1280
+ // → "ALICE"
1281
+
1282
+ // Static analysis uses the declared returnType
1283
+ engine.analyze("{{uppercase name}}", {
1284
+ type: "object",
1285
+ properties: { name: { type: "string" } },
1286
+ }).outputSchema;
1287
+ // → { type: "string" }
1288
+ ```
1289
+
1290
+ Helpers can also be passed at construction time:
1291
+
1292
+ ```ts
1293
+ const engine = new Typebars({
1294
+ helpers: [
1295
+ {
1296
+ name: "uppercase",
1297
+ fn: (value) => String(value).toUpperCase(),
1298
+ params: [{ name: "value", type: { type: "string" } }],
1299
+ returnType: { type: "string" },
1300
+ },
1301
+ {
1302
+ name: "double",
1303
+ fn: (value) => Number(value) * 2,
1304
+ params: [{ name: "value", type: { type: "number" } }],
1305
+ returnType: { type: "number" },
1306
+ },
1307
+ ],
1308
+ });
1309
+ ```
1310
+
1311
+ `registerHelper` returns `this` for chaining:
1312
+
1313
+ ```ts
1314
+ engine
1315
+ .registerHelper("upper", { fn: (v) => String(v).toUpperCase(), returnType: { type: "string" } })
1316
+ .registerHelper("lower", { fn: (v) => String(v).toLowerCase(), returnType: { type: "string" } });
1317
+ ```
1318
+
1319
+ ### `defineHelper` (Type-Safe)
1320
+
1321
+ `defineHelper()` infers the TypeScript types of your `fn` arguments from the JSON Schemas declared in `params`:
1322
+
1323
+ ```ts
1324
+ import { Typebars, defineHelper } from "typebars";
1325
+
1326
+ const concatHelper = defineHelper({
1327
+ name: "concat",
1328
+ description: "Concatenates two strings",
1329
+ params: [
1330
+ { name: "a", type: { type: "string" }, description: "First string" },
1331
+ { name: "b", type: { type: "string" }, description: "Second string" },
1332
+ { name: "sep", type: { type: "string" }, description: "Separator", optional: true },
1333
+ ] as const,
1334
+ fn: (a, b, sep) => {
1335
+ // TypeScript infers: a: string, b: string, sep: string | undefined
1336
+ return `${a}${sep ?? ""}${b}`;
1337
+ },
1338
+ returnType: { type: "string" },
1339
+ });
1340
+
1341
+ const engine = new Typebars({ helpers: [concatHelper] });
1342
+ ```
1343
+
1344
+ ---
1345
+
1346
+ ## Template Identifiers (`{{key:N}}`)
1347
+
1348
+ The `{{key:N}}` syntax references variables from **different data sources**, identified by a numeric ID. Useful in workflow engines or multi-step pipelines.
1349
+
1350
+ ### Analysis with Identifier Schemas
1351
+
1352
+ Each identifier maps to its own JSON Schema:
1353
+
1354
+ ```ts
1355
+ const engine = new Typebars();
1356
+
1357
+ const inputSchema = { type: "object", properties: {} };
1358
+
1359
+ const identifierSchemas = {
1360
+ 1: {
1361
+ type: "object",
1362
+ properties: { meetingId: { type: "string" } },
1363
+ },
1364
+ 2: {
1365
+ type: "object",
1366
+ properties: { leadName: { type: "string" } },
1367
+ },
1368
+ };
1369
+
1370
+ // ✅ Valid — meetingId exists in identifier 1's schema
1371
+ engine.analyze("{{meetingId:1}}", inputSchema, identifierSchemas);
1372
+ // → valid: true, outputSchema: { type: "string" }
1373
+
1374
+ // ❌ Invalid — identifier 1 doesn't have "badKey"
1375
+ engine.analyze("{{badKey:1}}", inputSchema, identifierSchemas);
1376
+ // → valid: false, code: IDENTIFIER_PROPERTY_NOT_FOUND
1377
+
1378
+ // ❌ Invalid — identifier 99 doesn't exist
1379
+ engine.analyze("{{meetingId:99}}", inputSchema, identifierSchemas);
1380
+ // → valid: false, code: UNKNOWN_IDENTIFIER
1381
+
1382
+ // ❌ Invalid — identifiers used but no schemas provided
1383
+ engine.analyze("{{meetingId:1}}", inputSchema);
1384
+ // → valid: false, code: MISSING_IDENTIFIER_SCHEMAS
1385
+ ```
1386
+
1387
+ ### Mixing Identifier and Regular Expressions
1388
+
1389
+ Regular expressions validate against `inputSchema`, identifier expressions against `identifierSchemas`:
1390
+
1391
+ ```ts
1392
+ const schema = {
1393
+ type: "object",
1394
+ properties: { name: { type: "string" } },
1395
+ };
1396
+
1397
+ const idSchemas = {
1398
+ 1: {
1399
+ type: "object",
1400
+ properties: { meetingId: { type: "string" } },
1401
+ },
1402
+ };
1403
+
1404
+ // ✅ "name" validated against schema, "meetingId:1" against idSchemas[1]
1405
+ engine.analyze("{{name}} — {{meetingId:1}}", schema, idSchemas);
1406
+ // → valid: true
1407
+ ```
1408
+
1409
+ ### Execution with Identifier Data
1410
+
1411
+ ```ts
1412
+ const result = engine.execute(
1413
+ "Meeting: {{meetingId:1}}, Lead: {{leadName:2}}",
1414
+ {},
1415
+ {
1416
+ identifierData: {
1417
+ 1: { meetingId: "MTG-42" },
1418
+ 2: { leadName: "Alice" },
1419
+ },
1420
+ },
1421
+ );
1422
+ // → "Meeting: MTG-42, Lead: Alice"
1423
+
1424
+ // Single expression preserves type
1425
+ engine.execute("{{count:1}}", {}, {
1426
+ identifierData: { 1: { count: 42 } },
1427
+ });
1428
+ // → 42 (number)
1429
+ ```
1430
+
1431
+ ### `analyzeAndExecute` with Identifiers
1432
+
1433
+ ```ts
1434
+ const { analysis, value } = engine.analyzeAndExecute(
1435
+ "{{total:1}}",
1436
+ {},
1437
+ {},
1438
+ {
1439
+ identifierSchemas: {
1440
+ 1: { type: "object", properties: { total: { type: "number" } } },
1441
+ },
1442
+ identifierData: {
1443
+ 1: { total: 99.95 },
1444
+ },
1445
+ },
1446
+ );
1447
+
1448
+ analysis.valid; // true
1449
+ analysis.outputSchema; // { type: "number" }
1450
+ value; // 99.95
1451
+ ```
1452
+
1453
+ ---
1454
+
1455
+ ## Error Handling
1456
+
1457
+ ### `TemplateParseError`
1458
+
1459
+ Thrown when the template has invalid Handlebars syntax:
1460
+
1461
+ ```ts
1462
+ try {
1463
+ engine.execute("{{#if}}unclosed", {});
1464
+ } catch (err) {
1465
+ // err instanceof TemplateParseError
1466
+ // err.message → "Parse error: ..."
1467
+ // err.loc → { line, column } if available
1468
+ }
1469
+ ```
1470
+
1471
+ ### `TemplateAnalysisError`
1472
+
1473
+ Thrown when `execute()` is called with a `schema` option and the template fails validation:
1474
+
1475
+ ```ts
1476
+ try {
1477
+ engine.execute("{{unknown}}", data, {
1478
+ schema: { type: "object", properties: { name: { type: "string" } } },
1479
+ });
1480
+ } catch (err) {
1481
+ // err instanceof TemplateAnalysisError
1482
+ err.diagnostics; // TemplateDiagnostic[]
1483
+ err.errors; // only severity: "error"
1484
+ err.warnings; // only severity: "warning"
1485
+ err.errorCount; // number
1486
+ err.warningCount; // number
1487
+ err.toJSON(); // serializable for API responses
1488
+ }
1489
+ ```
1490
+
1491
+ The `toJSON()` method produces a clean structure for HTTP APIs:
1492
+
1493
+ ```ts
1494
+ // Express / Hono / etc.
1495
+ res.status(400).json(err.toJSON());
1496
+ // → {
1497
+ // name: "TemplateAnalysisError",
1498
+ // message: "Static analysis failed with 1 error(s): ...",
1499
+ // errorCount: 1,
1500
+ // warningCount: 0,
1501
+ // diagnostics: [{ severity, code, message, loc, source, details }],
1502
+ // }
1503
+ ```
1504
+
1505
+ ### Syntax Validation (No Schema)
1506
+
1507
+ For live editors, check syntax without a schema:
1508
+
1509
+ ```ts
1510
+ engine.isValidSyntax("Hello {{name}}"); // true
1511
+ engine.isValidSyntax("{{#if x}}yes{{/if}}"); // true
1512
+ engine.isValidSyntax("{{#if x}}oops{{/unless}}"); // false
1513
+ ```
1514
+
1515
+ ---
1516
+
1517
+ ## Configuration & API Reference
1518
+
1519
+ ### Constructor
1520
+
1521
+ ```ts
1522
+ const engine = new Typebars({
1523
+ astCacheSize: 256, // LRU cache for parsed ASTs (default: 256)
1524
+ compilationCacheSize: 256, // LRU cache for Handlebars compilations (default: 256)
1525
+ helpers: [], // Custom helpers to register at construction
1526
+ });
1527
+ ```
1528
+
1529
+ ### `TemplateInput`
1530
+
1531
+ All methods accept a `TemplateInput`:
1532
+
1533
+ ```ts
1534
+ type TemplateInput =
1535
+ | string // Handlebars template
1536
+ | number // Literal passthrough
1537
+ | boolean // Literal passthrough
1538
+ | null // Literal passthrough
1539
+ | TemplateInputObject // Object where each property is a TemplateInput
1540
+ ```
1541
+
1542
+ ### Methods
1543
+
1544
+ | Method | Description |
1545
+ |--------|-------------|
1546
+ | `analyze(template, inputSchema, identifierSchemas?)` | Validates template + infers output schema. Returns `AnalysisResult` |
1547
+ | `validate(template, inputSchema, identifierSchemas?)` | Like `analyze()` but without `outputSchema`. Returns `ValidationResult` |
1548
+ | `execute(template, data, options?)` | Renders the template. Options: `{ schema?, identifierData?, identifierSchemas? }` |
1549
+ | `analyzeAndExecute(template, inputSchema, data, options?)` | Analyze + execute in one call. Returns `{ analysis, value }` |
1550
+ | `compile(template)` | Returns a `CompiledTemplate` (parse-once, execute-many) |
1551
+ | `isValidSyntax(template)` | Syntax check only (no schema needed). Returns `boolean` |
1552
+ | `registerHelper(name, definition)` | Register a custom helper. Returns `this` |
1553
+ | `unregisterHelper(name)` | Remove a helper. Returns `this` |
1554
+ | `hasHelper(name)` | Check if a helper is registered |
1555
+ | `clearCaches()` | Clear all internal caches |
1556
+
1557
+ ### `AnalysisResult`
1558
+
1559
+ ```ts
1560
+ interface AnalysisResult {
1561
+ valid: boolean; // true if no errors
1562
+ diagnostics: TemplateDiagnostic[]; // errors + warnings
1563
+ outputSchema: JSONSchema7; // inferred output type
1564
+ }
1565
+ ```
1566
+
1567
+ ### `CompiledTemplate`
1568
+
1569
+ Returned by `engine.compile()`. Has the same methods but without re-parsing:
1570
+
1571
+ | Method | Description |
1572
+ |--------|-------------|
1573
+ | `execute(data, options?)` | Render with data |
1574
+ | `analyze(inputSchema, identifierSchemas?)` | Validate + infer output schema |
1575
+ | `validate(inputSchema, identifierSchemas?)` | Validate only |
1576
+ | `analyzeAndExecute(inputSchema, data, options?)` | Both at once |
1577
+
1578
+ ---
1579
+
1580
+ ## License
1581
+
1582
+ MIT