typebars 1.0.18 → 1.0.19

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