Development
API reference, TypeScript input inference, migration guidance, and repository workflows in one place. For validators, transformers, and custom extensions, see Plugins.
- API reference: construction, normalization, extraction, rendering, errors, and highlighting.
- TypeScript input inference: literal templates, fallbacks, reusable types, and runtime limits.
- Migration and project development: migrating callers, working on the library, documentation, and releases.
API reference
import {
PayloadTemplate,
PayloadTemplateError,
type AllowedValue,
type BaseType,
type FallbackExpression,
type ParsedVariableExpression,
type PayloadVariable,
type PayloadVariableType,
type PayloadTemplateIssue,
type JsonPrimitive,
type JsonObject,
type JsonValue,
type JsonTemplateValue,
type PayloadTemplateVariables,
} from 'payload-vars';
Construction and validation
new PayloadTemplate(template) (accepting JsonTemplateValue) validates all placeholders and repeated variable contracts, then stores a private, normalized snapshot and compiled contract. Invalid syntax or incompatible source base types throw PayloadTemplateError with original template paths. Construction does not require runtime variables or evaluate fallbacks.
const template = new PayloadTemplate({
products: '{{ products : string[??omit]??throw }}',
});
Compilation happens once in the constructor; the internal buildContract() function builds the reusable contract. The public variables() method returns copies of the variable contracts; it and render() reuse the validated contract. Later changes to the original input cannot change the instance.
Plugin configuration
Pass PayloadTemplateOptions as the second constructor argument to register custom plugins. Built-in operations remain available regardless of this configuration. See Plugins for configuration, custom callbacks, and built-ins.
Normalized template
template.toJSON(): JsonValue returns an independent copy with canonical placeholder strings. Nested arrays and objects are copied; literal values and object keys are preserved.
template.toJSON();
// { products: '{{products:string[ ?? omit ] ?? throw}}' }
JSON.stringify(template, null, 2); // Indented, normalized JSON
The constructor accepts already parsed JSON. Normalization is idempotent. Editing a returned template cannot change future method results.
Variable contracts
template.variables(): PayloadVariable[] returns the variable contracts in first occurrence order, deduplicating matching declarations. Each call returns fresh objects, including nested fallback expressions and operation arrays.
template.variables();
// [{
// name: 'products',
// type: 'string[]',
// memberFallback: { operator: '??', action: 'omit' },
// valueFallback: { operator: '??', action: 'throw' },
// declaration: '{{products:string[ ?? omit ] ?? throw}}',
// paths: ['$.products'],
// }]
BaseType contains only the five supported base types. PayloadVariableType aliases BaseType. AllowedValue is string | number | boolean. ParsedVariableExpression contains name, type, optional allowedValues, optional opaque interaction, optional memberFallback, and optional valueFallback. PayloadVariable adds the canonical declaration string. Optional memberOperations and valueOperations contain ordered PayloadOperation objects ({ kind: 'validator' | 'transformer', name: string, operator?: '?' | '~' }). The optional operator marks conditional validators (?) and alternative transformers (~); it is absent for ! and >. Absent domains, interactions, fallbacks, and empty operation lists are omitted from extracted objects. Returned allowedValues arrays are independent copies.
Rendering
template.render(variables: PayloadTemplateVariables<T>): JsonValue evaluates declared variables using the compiled contract and produces a fresh result. It ignores unused variables and treats inherited properties as missing. Template and supplied values are not mutated. Supplied arrays are copied.
Literal templates infer required variables and their input types. Dynamic templates accept Readonly<Record<string, unknown>>. See the TypeScript guide for fallback typing, readonly inputs, and limits.
Fallback evaluation precedes validation for both whole values and array members. For whole arrays only, || also matches an empty array. Rendering stops at the first error. Root omission is an error because the method returns a JSON value.
Runtime values are supplied per call and never stored on the instance. Reuse the same instance for multiple payloads; a failed render does not affect subsequent calls.
template.render({ products: ['A', null, 'B'] }); // { products: ['A', 'B'] }
template.render({ products: ['C'] }); // { products: ['C'] }
Errors
PayloadTemplateError.issue is a discriminated union. error.message is the issue code except for syntax errors, which provide guidance. Use error.issue.code for programmatic checks. Runtime values are never included in errors.
| Code | Details |
|---|---|
INVALID_PLACEHOLDER |
path, placeholder |
UNSUPPORTED_TYPE |
path, variableName, declaredType |
INVALID_FALLBACK_SYNTAX, INVALID_ALLOWED_VALUES_SYNTAX, INVALID_INTERACTION_SYNTAX |
path, variableName, placeholder |
VARIABLE_TYPE_CONFLICT |
variableName, expectedType, conflictingType, path |
INVALID_FUNCTION_ARGUMENTS, FUNCTION_RESULT_TYPE_MISMATCH, INVALID_OMIT_OPERATION |
path, variableName, operation |
UNKNOWN_FUNCTION_REFERENCE, FUNCTION_ARGUMENT_TYPE_MISMATCH |
path, variableName, operation, argumentName, argumentIndex, referencePath; type mismatches also include expectedType, actualType |
CYCLIC_FUNCTION_REFERENCE |
path, templatePaths (dependency chain including the repeated location) |
INVALID_FUNCTION_ARGUMENT |
Runtime details, operation, argumentName, argumentIndex, source, optional referencePath, and reason (omitted, null, or invalid-type) |
INVALID_FUNCTION_RESULT |
Runtime details, operation |
INVALID_PLUGIN_NAME, DUPLICATE_PLUGIN_NAME, RESERVED_PLUGIN_NAME |
plugin |
INVALID_PLUGIN_OPERATION_NAME, INVALID_PLUGIN_OPERATION |
kind, operation, plugin |
UNKNOWN_PLUGIN_OPERATION |
kind, operation, path, variableName |
VALIDATION_FAILED |
Runtime details, kind, operation, optional valuePath |
PLUGIN_EXECUTION_FAILED |
Runtime details, kind, operation, optional valuePath |
INVALID_TRANSFORMER_RESULT |
Runtime details, kind, operation, optional valuePath |
MISSING_VARIABLE |
Runtime details |
INVALID_VARIABLE_TYPE |
Runtime details, actualType, optional valuePath |
VALUE_NOT_ALLOWED |
Runtime details, optional valuePath |
FALLBACK_THROW |
Runtime details, operator, optional valuePath |
CANNOT_OMIT_ROOT |
Runtime details |
Runtime details are variableName, canonical declaration, expectedType (the base type), and templatePaths (all occurrences). Syntax errors include original template text because it cannot be normalized.
Paths start at $, use .key for identifier keys, and [0] for array positions. Other keys use bracketed JSON strings, such as $["order-id"]. Member errors include an original input valuePath, such as $[2], even when earlier members were omitted.
try {
const template = new PayloadTemplate({ amount: '{{amount:number || throw}}' });
template.render({ amount: 0 });
} catch (error) {
if (error instanceof PayloadTemplateError) {
switch (error.issue.code) {
case 'FALLBACK_THROW':
console.log(error.issue.variableName, error.issue.templatePaths);
break;
}
}
throw error;
}
Expression highlighting
template.tokenizePayloadExpression(): TokenizedPayloadExpression[] uses the normalized payload already stored by the constructor. Supply the input once at construction; no additional expression argument or normalization call is needed.
Each result contains a JSON path, the normalized expression including mustache delimiters, and its tokens. Results follow payload traversal order, include repeated occurrences, and omit literal values. A root expression has path $; a payload without expressions returns []. Each call returns independent objects and arrays.
Each token has kind, exact normalized text, and start (inclusive) and end (exclusive) UTF-16 offsets relative to that entry’s expression, not the serialized JSON. Concatenating its token texts reproduces the normalized expression. TokenizedPayloadExpression, PayloadExpressionToken, and PayloadExpressionTokenKind are exported types.
Kinds are delimiter, variable, punctuation, type, operator, action, validator, transformer, function, interaction, keyword, value, whitespace, and unknown. The allowed-values in token is a keyword, domain entries are value tokens, and the name after the final @ is an interaction token. Keywords are classified by position: string is a variable in {{string:boolean}}. Constructor validation rejects incomplete or invalid expressions before tokenization; original whitespace is not retained. Tokenization does not change validation, rendering, or the stored payload, and does not tokenize surrounding JSON.
The consumer chooses styling, for example with arbitrary CSS classes:
import { PayloadTemplate, type PayloadExpressionTokenKind } from 'payload-vars';
const template = new PayloadTemplate({ products: '{{products:string[??omit]??throw}}' });
const classes: Record<PayloadExpressionTokenKind, string> = {
delimiter: 'muted', variable: 'blue', punctuation: 'muted', type: 'purple',
operator: 'orange', action: 'green', validator: 'blue', transformer: 'purple', function: 'purple',
interaction: 'blue', keyword: 'orange', value: 'green', whitespace: 'plain', unknown: 'underlined',
};
for (const { path, tokens } of template.tokenizePayloadExpression()) {
const container = document.createElement('pre');
container.dataset.path = path;
for (const token of tokens) {
const span = document.createElement('span');
span.className = classes[token.kind];
span.textContent = token.text;
container.append(span);
}
document.body.append(container);
}
The method returns only token data; CSS classes and DOM elements above belong entirely to consumer code.
TypeScript input inference
PayloadTemplate infers the variables accepted by render() from literal templates. This requires TypeScript 5.0 or newer. JavaScript users retain the same runtime validation.
import { PayloadTemplate } from 'payload-vars';
const template = new PayloadTemplate({
orderId: '{{orderId:string}}',
amount: '{{amount:number}}',
products: '{{products:string[ ?? omit ]}}',
comment: '{{comment:string ?? omit}}',
});
template.render({
orderId: 'ORD-123',
amount: 19.95,
products: ['A', null, 'B'],
// comment is optional
});
// TypeScript errors: orderId must be a string and amount must be a number.
template.render({ orderId: 123, amount: '19.95', products: [] });
Inline constructor arguments preserve literals automatically. When declaring a template separately, use as const to prevent placeholder strings from widening:
const rawTemplate = {
items: ['{{id:string}}', { amount: '{{amount:number}}' }],
} as const;
const template = new PayloadTemplate(rawTemplate);
template.render({ id: 'ORD-123', amount: 19.95 });
Nested objects and arrays are inspected. Object keys are literal and do not declare variables. Repeated variables share one input property. Readonly template arrays and readonly supplied arrays are supported. Extra variables remain allowed, matching the runtime behavior that ignores unused inputs.
Derived inputs and repeated expressions
Raw $.name function arguments are required inputs; derived output names are excluded. date.interval infers two string arguments. Custom signatures are inferred from literal constructor options. Preserve plugin names and argument tuples with as const satisfies PayloadVarsPlugin. PayloadTemplateVariables<T, Options> accepts an optional second type parameter for custom plugin options. If metadata has been widened, raw argument names remain required with unknown types, and runtime validation enforces the signature.
variables() groups identical canonical declarations and now always includes all occurrence paths, including ordinary declarations. Distinct uses of a name appear separately with their own declaration and paths. Derived entries include derived: true, function: { name, arguments }, and ordered dependencies: { source: "input", name } for raw inputs or { source: "property", path } for evaluated locations. Following property dependencies reaches ordinary input contracts or further derived calls. Original inputs used only as raw function arguments are appended in first-use order with functionArgument: true, their declared type, a synthetic plain declaration, and the paths of calls using them. Returned metadata is copied and may be edited without affecting the template.
Literal templates infer original inputs from every ordinary placeholder and every $.name argument, including those reached through derived-property chains. Bare references never create input requirements: their source placeholders already supply those requirements. Result names are never required as inputs. Referenced ordinary placeholders retain their fallback optionality; raw arguments remain required even when a result has a fallback. Repeated input requirements are intersected.
Fallback input types
| Declaration | Inferred input |
|---|---|
string |
Required string |
number |
Required number |
boolean |
Required boolean |
string[] |
Required readonly string[] |
number[] |
Required readonly number[] |
string ?? null or string ?? omit |
Optional string \| null \| undefined |
string ?? throw |
Required string |
string \|\| null or string \|\| omit |
Optional string \| null \| undefined \| false \| 0 \| 0n |
boolean \|\| throw |
Required true |
string[ ?? omit ] |
Required readonly (string \| null \| undefined)[] |
number[ \|\| null ] ?? omit |
Optional array whose members accept numbers and representable falsy values, or a nullish whole value |
string[] \|\| throw |
Required array; an empty array throws at runtime |
For null and omit, fallback inputs are added to the declared base type. ?? admits null and undefined; || also admits '', 0, false, and 0n. Whole-value fallbacks make the property optional. Member fallbacks affect members independently and do not make the whole array optional.
throw describes a requirement to the caller: values must satisfy the base type and cannot be nullish or missing. For boolean || throw, TypeScript can also exclude false. For a whole array, || throw additionally rejects [] at runtime. TypeScript cannot generally prove that an array is non-empty, so runtime validation remains authoritative.
Reusable input types
PayloadTemplateVariables<T> exposes the inferred input contract:
import { PayloadTemplate, type PayloadTemplateVariables } from 'payload-vars';
const rawTemplate = { amount: '{{amount:number}}' } as const;
type Variables = PayloadTemplateVariables<typeof rawTemplate>;
const values: Variables = { amount: 19.95 };
new PayloadTemplate(rawTemplate).render(values);
Dynamic templates and runtime validation
Templates loaded from JSON or otherwise containing widened string values cannot provide a complete compile-time contract. They accept Readonly<Record<string, unknown>>, with validation performed at runtime. This applies to the whole contract if any template value is dynamic. Type inference also falls back to runtime validation at 16 nested traversal levels to bound compiler work.
import { PayloadTemplate, type JsonTemplateValue } from 'payload-vars';
const rawTemplate: JsonTemplateValue = JSON.parse(jsonText);
const template = new PayloadTemplate(rawTemplate);
template.render(runtimeValues);
JsonTemplateValue accepts mutable or deeply readonly JSON trees. To deliberately use runtime validation with a known template and untyped external values, widen the instance explicitly:
const template = new PayloadTemplate<JsonTemplateValue>({
amount: '{{amount:number}}',
});
const values: Record<string, unknown> = { amount: 'invalid' };
template.render(values); // Compiles; throws INVALID_VARIABLE_TYPE at runtime.
TypeScript inference supplements validation; it does not replace it:
numberincludesNaNand infinities. Runtime validation enforces finite numbers.- TypeScript cannot subtract
0fromnumberor''fromstring. Such values still compile for|| throwand fail at runtime. - TypeScript cannot generally exclude an empty array.
[]may compile for a whole-array|| throwdeclaration and then raiseFALLBACK_THROWat runtime. NaNhas no distinct literal type. A non-number|| nullor|| omitdeclaration accepts it at runtime, but its inferred input excludes general numbers other than0.- Syntax errors and conflicting declarations are still reported by the constructor. The type layer does not promise compile-time syntax diagnostics.
render()andtoJSON()still returnJsonValue, andvariables()returnsPayloadVariable[]. Precise output inference is outside this input-inference feature.
Migration and project development
Migrating variable inspection
To migrate from 2.0.1, replace template.compile() with template.variables(). The return type and behavior are unchanged; compilation still happens once in the constructor.
For earlier class-based callers, replace template.extractVariables() with template.variables().
Documentation website
GitHub Pages publishes docs/ from main at https://thismeansmore.github.io/payload-vars/. Changes pushed to main rebuild the site automatically. Relative .md links work on GitHub and are converted to website links during the build.
The homepage is generated from README, the package version, and docs/_includes/home-footer.md; the changelog and its archive are generated from CHANGELOG and CHANGELOG-ARCHIVE. Edit those sources. The changelog keeps the latest five releases in full; release preparation archives older entries and links to them with one-line summaries. Builds and tests refresh the pages automatically, or use npm run docs:sync. The shared generator in scripts/lib/docs.mjs adjusts links and protects template expressions from Liquid processing.
Run npm run playground to build the current local package and preview the playground without installing Jekyll. The build copies the browser-compatible ESM modules into docs/assets/payload-vars/; both the local server and GitHub Pages load that self-hosted copy, so playground behavior always matches the repository commit without depending on npm CDN aliases. Page, asset, and rebuilt package changes are read directly from disk and appear after a browser refresh, while watch mode restarts only when the server implementation changes. Run npm run build after further source changes. The command prints Local, Private, and other Network addresses and listens on all IPv4 interfaces by default. Set PAYLOAD_VARS_PLAYGROUND_HOST=127.0.0.1 for local-only access, or set PAYLOAD_VARS_PLAYGROUND_PORT to use a different port.
Site settings and navigation are in _config.yml. Page content is wrapped in Liquid raw tags inside HTML comments to preserve literal template examples while keeping the comments hidden on GitHub. Keep these wrappers when editing pages. New pages should include YAML front matter with a title; add them to header_pages to include them in navigation.
Migrating from functions to the class
The public API now exposes PayloadTemplate instead of the standalone validation, extraction, and rendering functions. Create one instance per template and reuse it:
import { PayloadTemplate } from 'payload-vars';
const template = new PayloadTemplate(rawTemplate); // Validate once
const normalized = template.toJSON();
const variables = template.variables();
const payload = template.render(values);
Replace validatePayloadTemplate(rawTemplate) with construction followed by toJSON(), extractPayloadVariables(rawTemplate) with variables(), and renderPayloadTemplate(rawTemplate, values) with render(values). Template syntax and conflict errors now occur during construction; runtime value errors occur during rendering. The standalone functions are no longer exported.
Instances snapshot their input. To change a template, create a new instance. Returned templates and extracted contracts are independent copies. Runtime values are passed to each render call and are not retained.
Migrating from suffix syntax
This is a breaking contract change. Nullable suffixes such as string? and string[]? are no longer supported; ! and _ are not modifiers either.
Choose explicit fallback behavior:
- Use
string ?? nullto replace nullish values withnullwhile preserving empty strings. - Use
string || nullto replace all falsy values withnull. - Use
number ?? nullandboolean ?? nullto preserve0andfalse. - Use
string[] ?? nullfor a nullish whole array. - Use
string[ ?? null ]to allow nullish members as JSONnull. - Use
?? omitor|| omitto remove a value from its container.
The old nullable behavior has no exact universal replacement. ?? preserves empty arrays; a whole-array || treats [] as empty and applies its fallback action. Normalize other special business values before supplying variables if needed.
Extracted variables now include a canonical declaration and optional structured fallbacks. The type field always contains a base type. Incompatible source base types use VARIABLE_TYPE_CONFLICT; differing operations and fallbacks are allowed. Use canonical declaration fields when displaying contracts. Runtime issues now also include declaration. See API and errors.
Migrating TypeScript callers
Literal templates now infer render() inputs. Existing TypeScript callers may receive errors for missing or incompatible values previously caught only at runtime. throw fallbacks require supplied values. Preserve literal types with as const for separately declared templates; use PayloadTemplate<JsonTemplateValue> when accepting untyped external values for runtime validation. See the TypeScript guide.
TypeScript consumers need version 5.0 or newer for the class’s const type parameter. JavaScript runtime requirements are unchanged.
Local development
npm ci
npm test
Unit suites separate compilation (valid syntax, invalid combinations, and contract errors), operation execution, plugin behavior, extraction, highlighting, template snapshots, and rendering/fallbacks. They cover !, ?, and ~ in scalar, member, and collection scopes. Compile-time tests use positive cases and @ts-expect-error assertions and run as part of both npm test and npm run typecheck. The package has no runtime dependencies. Error codes and issue shapes are public API.
Templates must be parsed JSON trees. Raw JSON parsing, partial interpolation, object variables, and boolean[] are outside this API. Plugins provide date and email validation and transformations that preserve the declared type.
Contributing and releasing
Follow the contribution guide for the release-branch PR workflow and mandatory changelog review. Preparation runs only on the exact release/vX.Y.Z branch; publication runs only after that PR is merged into clean, synchronized main and never commits or pushes main. Release orchestration lives in scripts/prepare-version.mjs and scripts/publish-version.mjs; shared release and docs helpers live in scripts/lib/.