A fast, allocation-conscious text templating engine for .NET
$ dotnet add package Intercode.Toolbox.TemplateEngineTemplate Engine is a fast, allocation-conscious text templating engine for .NET designed for high-throughput scenarios. Compile templates once and expand them many times with minimal allocations and predictable performance. It supports static and dyynamic macro values, compile-time include expansion, and positional, static-only hot paths.
A template is a string containing macros (placeholders) that are replaced at runtime with values. Example:
Hello, $Name$! Today is $NOW:yyyyMMdd$.
:) are passed to dynamic macro generators.NOW, GUID) are always available and do not need to be declared.Install the Intercode.Toolbox.TemplateEngine package .
dotnet add package Intercode.Toolbox.TemplateEngine
Create a template, provide macro values, and process it. The most common approaches are shown below.
Simplest flow (auto-declare macros while compiling)
//1. Compile template (macros encountered in text are declared automatically)
var template = TemplateCompiler.Compile(
"Hello, $Name$! Today is $NOW:yyyyMMdd$. You are $Age$ years old!"
);
//2. Provide values (static and/or dynamically generated)
var values = template.CreateValues()
.SetValue("Name", "John")
.SetValue("Age", _ => Random.Shared.Next(18,100).ToString());
//3. Process the template
var text = template.ProcessMacros(values);
Explicit macro declaration and shared macro table
//1. Declare user macros
var macroTable = new MacroTableBuilder()
.Declare("Name")
.Declare("Age")
.Build();
//2. Compile template
var template = TemplateCompiler.Compile(
"Hello, $Name$! Today is $NOW:yyyyMMdd$. You are $Age$ years old!",
macroTable
);
//3. Provide values
var values = macroTable.CreateValues()
.SetValue("Name", "John")
.SetValue("Age", _ => Random.Shared.Next(18,100).ToString());
//4. Process the template
var text = template.ProcessMacros(values);
Static-only scenario (no dynamic generators), using MacroValues with static strings:
var table = new MacroTableBuilder()
.Declare("Name")
.Build();
var template = TemplateCompiler.Compile("Hello, $Name$!", table);
var values = table.CreateValues()
.SetValue("Name", "World");
var text = template.ProcessMacros(values);
// Or use positional overloads for high-throughput scenarios
var text2 = template.ProcessMacros("World");
Choosing a
Compileoverload
Compile(string text): Easiest on-ramp. Macros are auto-declared as they are encountered in the template. The declaration (slot) order follows the first occurrence of each macro placeholder in the template text after include expansion. Usetemplate.CreateValues()to provide values. Good for one-off templates where you don’t need to share a macro table.Compile(string text, MacroTableBuilder builder, ...): Simplifies usage while still letting you accumulate macro declarations into a builder you control. Macros are declared into the builder in the order they first appear in the template text (post-include). Pass the same builder to multipleCompilecalls to keep a shared mapping; slots are assigned in the sequence templates are compiled and, within each template, by first occurrence. Note that a newMacroTableinstance is built for each compiled template. .Compile(string text, MacroTable macroTable, ...): Maximum control. You decide declaration order and reuse the sameMacroTableacross many templates. This guarantees consistent slot ordering and is recommended for high-throughput scenarios that must share values containers and avoid churn.
Compile(string text) (order is first-encounter in text, after includes).MacroTableBuilder to collect declarations as templates are compiled (order is first-encounter per compiled template, after includes), then call Build() to produce a reusable MacroTable.MacroTable to multiple Compile calls when you need consistent slot ordering across templates.Built-in, dynamic macros are always available. See Standard Macros.
Dynamic macros defer value creation until expansion; they can incorporate timestamps, randomness, environment inspection, etc. Generators can read an optional argument to customize the generated value.
Includes are macros that are substituted at compile-time. They are useful for static blocks (e.g., license headers, namespaces) that do not change per expansion and may contain run-time macros themselves.
Arguments are opaque to the engine—no parsing or validation is performed—so generators can interpret them according to domain rules (e.g., format strings or numeric bounds).
TemplateCompilerOptions.MacroDelimiter (default: $).TemplateCompilerOptions.ArgumentSeparator (default :).###1. Declare Macros
var macroTable = new MacroTableBuilder()
.Declare("Name")
.Declare("Age")
.Build();
Alternatively, use auto-declaration by compiling with Compile(string text) or track declarations by passing a MacroTableBuilder. In both cases, macros are declared in the order they are first encountered in the template text after include expansion.
###2. Compile the Template Text
var template = TemplateCompiler.Compile(
"Hello, $Name$! Today is $NOW:yyyyMMdd$. You are $Age$ years old!",
macroTable
);
With optional includes:
var includes = new IncludesCollection();
includes.AddInclude("HEADER", "// <auto-generated>...</auto-generated>\n");
var template = TemplateCompiler.Compile("$HEADER$namespace $Name$;", macroTable, includes);
Or use auto-declaration:
var template = TemplateCompiler.Compile("$HEADER$namespace $Name$;", includes);
Or accumulate declarations using a MacroTableBuilder:
var builder = new MacroTableBuilder();
var template = TemplateCompiler.Compile("$HEADER$namespace $Name$;", builder, includes);
// Later, lock mapping:
var table = builder.Build();
###3. Define Macro Values
var values = macroTable.CreateValues()
.SetValue("Name", "John")
.SetValue("Age", _ => Random.Shared.Next(18,100).ToString());
When using auto-declaration:
var values = template.CreateValues()
.SetValue("Name", "John")
.SetValue("Age", _ => Random.Shared.Next(18,100).ToString());
###4. Process the Template
var sb = new StringBuilder();
template.ProcessMacros(sb, values);
var text = sb.ToString();
// or
var text2 = template.ProcessMacros(values);
Standard macros are always available; you do not need to declare them. Names are case-insensitive.
NOW[:<format>]format is passed directly to DateTime.ToString(format); example: $NOW:yyyyMMdd$.UTC_NOW[:<format>]format behaves like NOW; example: $UTC_NOW:O$.GUID[:<format>]format can specify standard GUID formats (e.g., N, D, B, P); example: $GUID:N$.ENV:<variable>$ENV:PATH$.MACHINEEnvironment.MachineName; no argument.OSEnvironment.OSVersion.VersionString; no argument.USEREnvironment.UserName; no argument.CLR_VERSIONEnvironment.Version; no argument.Note. Arguments are not parsed by the engine; they are passed to the macro generator as-is.
MacroValues to bind static strings and/or dynamic generators per declared macro name or slot.MacroProcessor that accept a string?[] or ReadOnlySpan<string>. The array/span
must have one entry per declared macro, ordered by declaration. Standard macros are resolved
automatically and do not consume a slot.Immutable configuration applied by the template compiler. Options instances can be cached and shared across threads.
public sealed class TemplateCompilerOptions
TemplateCompilerOptions() – Initializes with default settings (macro delimiter: $, argument separator: :).TemplateCompilerOptions(char macroDelimiter, char argumentSeparator) – Initializes with custom delimiter and separator. Throws if values are invalid or equal.TemplateCompilerOptions Default – Shared default options instance.| Property | Type | Description |
|---|---|---|
MacroDelimiter | char | Gets the character used as the macro delimiter in the template engine. |
ArgumentSeparator | char | Gets the character used to separate arguments within a macro. |
var options = new TemplateCompilerOptions('#', '|');
var template = TemplateCompiler.Compile("#Name|formal#", options: options);
Declares the list of user macro names permitted in a template set. Provides deterministic macro slot ordering.
public sealed class MacroTableBuilder
MacroTableBuilder() – Initializes a new instance.| Method | Return Type | Description |
|---|---|---|
Declare(string macroName) | MacroTableBuilder | Registers a macro identifier (case-insensitive). Duplicate declarations are ignored. |
Declare(ReadOnlySpan<char> macroName) | MacroTableBuilder | Registers a macro identifier using a span (optimized on .NET9+ call sites). |
Build() | MacroTable | Materializes an immutable macro name mapping table. |
TemplateCompiler.Compile(string text) (implicit builder) or TemplateCompiler.Compile(string text, MacroTableBuilder builder, ...), macros are declared in the order they are first encountered in the template text after include expansion. This order determines slot assignment.Immutable mapping from user macro name (case-insensitive) to a slot number.
public sealed class MacroTable
| Property | Type | Description |
|---|---|---|
Count | int | Number of declared user macros. |
| Method | Return Type | Description |
|---|---|---|
CreateValues() | MacroValues | Creates a MacroValues instance for the macro table. |
GetSlot(string macroName) | int | Resolves the slot for a name; returns 0 if the name is unknown. |
GetSlot(ReadOnlySpan<char> macroName) | int | Resolves the slot by span (optimized on .NET9+ call sites). |
User-provided callback that generates a dynamic value for a macro.
delegate string? MacroValueGenerator(ReadOnlySpan<char> argument);
ReadOnlySpan<char> argument – The macro argument from the template text; empty if no value was provided.string? – The generated macro value, or null.Mutable container binding user macro slots to static strings or generator delegates. Designed for rapid reassignment with minimal allocations.
public sealed class MacroValues
| Property | Type | Description |
|---|---|---|
MacroTable | MacroTable | The macro table associated with this values container. |
| Method | Return Type | Description |
|---|---|---|
SetValue(string macroName, string? value) | MacroValues | Associates a static string value with a macro by name; null clears the macro's value. |
SetValue(string macroName, MacroValueGenerator? generator) | MacroValues | Associates a dynamic generator with a macro by name; null clears the macro's value. |
SetValue(int slot, string? value) | MacroValues | Associates a static string with a macro by slot number; null clears the macro's value. |
SetValue(int slot, MacroValueGenerator? generator) | MacroValues | Associates a dynamic generator with a macro slot by index; null clears the slot. |
SetValue(ReadOnlySpan<char> macroName, string? value) | MacroValues | Associates a static value by span-based macro name (optimized on .NET9+ call sites). |
SetValue(ReadOnlySpan<char> macroName, MacroValueGenerator? generator) | MacroValues | Associates a dynamic generator by span-based macro name (optimized on .NET9+ call sites). |
GetValue(string macroName, ReadOnlySpan<char> argument = default) | string? | Fetches the value by macro name, invoking a dynamic generator if present. |
GetValue(int slot, ReadOnlySpan<char> argument = default) | string? | Fetches the value by slot number. Returns null when not assigned or the slot is invalid. Negative slots resolve to standard macros. |
GetValue(ReadOnlySpan<char> macroName, ReadOnlySpan<char> argument = default) | string? | Fetches the value by span-based macro name (optimized on .NET9+ call sites). |
Converts the raw template text plus optional include expansions into a Template structure.
public static class TemplateCompiler
| Method | Return Type | Description |
|---|---|---|
Compile(string text, MacroTable macroTable, IncludesCollection? includes = null, TemplateCompilerOptions? options = null) | Template | Parses the template text into segments, performs include substitution (if provided), and resolves slots using the supplied MacroTable. Use when you want strict, shared ordering across templates. |
Compile(string text, MacroTableBuilder builder, IncludesCollection? includes = null, TemplateCompilerOptions? options = null) | Template | Parses the template and declares any encountered macros into the provided builder in the order they first appear in the template (after include expansion), then builds a MacroTable for the returned Template. Pass the same builder to multiple calls to accumulate a shared mapping before calling Build(). |
Compile(string text, IncludesCollection? includes = null, TemplateCompilerOptions? options = null) | Template | Convenience overload that auto-declares macros using an internal MacroTableBuilder. Macros are declared in the order they are first encountered in the template text after include expansion. Simplest usage; create values with template.CreateValues(). |
Executes expansion by processing a compiled Template instance.
public static class MacroProcessor
| Method | Return Type | Description |
|---|---|---|
ProcessMacros(this Template template, TextWriter writer, MacroValues macroValues) | void | Streams expanded output to a TextWriter. Missing macro values produce empty strings. Exceptions thrown by generators are caught and their messages are emitted. |
ProcessMacros(this Template template, StringBuilder builder, MacroValues macroValues) | void | Appends expanded output into a StringBuilder. |
ProcessMacros(this Template template, MacroValues macroValues) | string | Expands the template and returns the resulting string using an internally pooled StringBuilder. |
ProcessMacros(this Template template, StringBuilder builder, params ReadOnlySpan<string?> values) | void | Higher-performance, positional, static values variant; span length must be at least MacroTable.Count. Standard macros are resolved automatically. |
ProcessMacros(this Template template, StringBuilder builder, params string?[] values) | void | Convenience overload for arrays. |
ProcessMacros(this Template template, params ReadOnlySpan<string?> values) | string | Returns the expanded string using positional values and an internally pooled StringBuilder. |
ProcessMacros(this Template template, params string?[] values) | string | Convenience overload for arrays using an internally pooled StringBuilder. |
Container for compile-time macro expansion. Supports static text or dynamic generators.
public sealed class IncludesCollection
| Property | Type | Description |
|---|---|---|
Count | int | Number of include entries tracked. |
| Method | Return Type | Description |
|---|---|---|
AddInclude(string name, string? content) | void | Adds or replaces a static include. A null content becomes an empty string at expansion. |
AddInclude(string name, MacroValueGenerator? generator = null) | void | Adds or replaces a dynamic include whose content is generated at compile-time. A null generator produces an empty string. |
Represents a compiled template with its associated macro table and text. Provides access to the original template text and allows creation of macro value containers for expansion.
public readonly record struct Template
| Property | Type | Description |
|---|---|---|
MacroTable | MacroTable | The macro table associated with this template. |
Text | string | The original template text (after include expansion, if any). |
| Method | Return Type | Description |
|---|---|---|
CreateValues() | MacroValues | Creates a new MacroValues instance associated with the template's macro table. |
var options = new TemplateCompilerOptions('#', '|');
var template = TemplateCompiler.Compile("#Name|formal#", options: options);
var values = template.CreateValues()
.SetValue("RightNow", arg => DateTime.Now.ToString(arg.IsEmpty ? "O" : arg.ToString()));
Escaping delimiters: $$ yields a literal $ in output when $ is the delimiter.
High-throughput assignment using slots:
var nameSlot = template.MacroTable.GetSlot("Name");
var values = template.CreateValues();
values.SetValue(nameSlot, "John");
values.SetValue(nameSlot, arg => expensiveComputation(arg));
values.SetValue("Name".AsSpan(), "John");
var value = values.GetValue("Name".AsSpan());
// Values must be ordered as macros were declared; standard macros do not consume positions
var text = template.ProcessMacros("Benchmark.Tests", "TestType", /* ... more values ... */);
The code was benchmarked using BenchmarkDotNet. MacroProcessor demonstrates significantly faster performance and lower memory allocation compared to StringBuilder.Replace, regex, and naive string replacement. Using a pooled StringBuilder eliminates intermediate allocations and performs even better.
To benchmark the TemplateEngine we are going to parse the following template, taken from one of the standard templates
from the Intercode.Toolbox.TypedPrimitives package:
// <auto-generated> This file has been auto generated by Intercode Toolbox Typed Primitives. </auto-generated>
#nullable enable
namespace $Namespace$;
public partial class $TypeName$SystemTextJsonConverter: global::System.Text.Json.Serialization.JsonConverter<$TypeQualifiedName$>
{
public override bool CanConvert(
global::System.Type typeToConvert )
{
return typeToConvert == typeof( $TypeQualifiedName$ );
}
public override $TypeQualifiedName$ Read(
ref global::System.Text.Json.Utf8JsonReader reader,
global::System.Type typeToConvert,
global::System.Text.Json.JsonSerializerOptions options )
{
$TypeKeyword$? value = null;
if( reader.TokenType != global::System.Text.Json.JsonTokenType.Null )
{
if( reader.TokenType == global::System.Text.Json.JsonTokenType.$JsonTokenType$ )
{
value = $JsonReader$;
}
else
{
bool converted = false;
ConvertToPartial( ref reader, typeToConvert, options, ref value, ref converted );
if ( !converted )
{
throw new global::System.Text.Json.JsonException( "Value must be a $JsonTokenType$" );
}
}
}
var result = $TypeQualifiedName$.Create( value );
if( result.IsFailed )
{
throw new global::System.Text.Json.JsonException(
global::System.Linq.Enumerable.First( result.Errors )
.Message
);
}
return result.Value;
}
public override void Write(
global::System.Text.Json.Utf8JsonWriter writer,
$TypeQualifiedName$ value,
global::System.Text.Json.JsonSerializerOptions options )
{
if ( value.IsDefault )
{
writer.WriteNullValue();
return;
}
$JsonWriter$;
}
partial void ConvertToPartial(
ref global::System.Text.Json.Utf8JsonReader reader,
global::System.Type typeToConvert,
global::System.Text.Json.JsonSerializerOptions options,
ref $TypeKeyword$? value,
ref bool converted );
}
And we are going to use the following macro values:
| Macro Name | Value |
|---|---|
Namespace | Benchmark.Tests |
TypeName | TestType |
TypeQualifiedName | Benchmark.Tests.TestType |
TypeKeyword | string |
JsonTokenType | String |
JsonReader | reader.GetString() |
JsonWriter | writer.WriteStringValue( value.Value ) |
Using BenchmarkDotNet
[Config( typeof( Config ) )]
[MemoryDiagnoser]
[HideColumns( "Job", "Median" )]
public partial class MacroProcessingBenchmarks
{
// Removed for brevity. See content above.
public const string TemplateText = "";
// Removed for brevity. String.Format-compatible copy of TemplateText.
public const string TemplateTextFormat = "";
private readonly Template _template;
private readonly MacroValues _dynamicMacroValues;
private readonly CompositeFormat _compositeFormat;
public MacroProcessingBenchmarks()
{
// Declare macro table
var builder = new MacroTableBuilder();
foreach( var (macroName, _) in Macros )
{
builder.Declare( macroName );
}
var macroTable = builder.Build();
// Set macro values
_dynamicMacroValues = macroTable.CreateValues();
foreach( var (macroName, value) in Macros )
{
_dynamicMacroValues.SetValue( macroName, value );
}
// Compile templates
_template = TemplateCompiler.Compile( TemplateText, macroTable );
_compositeFormat = CompositeFormat.Parse( TemplateTextFormat );
}
public static IReadOnlyDictionary<string, string> Macros =>
new Dictionary<string, string>
{
{ "Namespace", "Benchmark.Tests" },
{ "TypeName", "TestType" },
{ "TypeQualifiedName", "Benchmark.Tests.TestType" },
{ "TypeKeyword", "string" },
{ "JsonTokenType", "String" },
{ "JsonReader", "reader.GetString()" },
{ "JsonWriter", "writer.WriteStringValue( value.Value )" }
};
[Benchmark]
public string MacroProcessor_WithStringParams()
{
return _template.ProcessMacros(
"MyApp.Primitives",
"Type",
"MyApp.Primitives.Type",
"string",
"String",
"reader.GetString()",
"writer.WriteStringValue( value.Value )"
);
}
[Benchmark]
public string MacroProcessor_WithMacrosValues()
{
return _template.ProcessMacros( _dynamicMacroValues );
}
[Benchmark]
public string StringFormat()
{
return string.Format(
TemplateTextFormat,
"MyApp.Primitives",
"Type",
"MyApp.Primitives.Type",
"string",
"String",
"reader.GetString()",
"writer.WriteStringValue( value.Value )"
);
}
[Benchmark]
public string StringFormat_WithCompositeFormat()
{
return string.Format(
CultureInfo.InvariantCulture,
_compositeFormat,
"MyApp.Primitives",
"Type",
"MyApp.Primitives.Type",
"string",
"String",
"reader.GetString()",
"writer.WriteStringValue( value.Value )"
);
}
[Benchmark]
public string StringBuilderReplace()
{
var sb = new StringBuilder( TemplateText );
foreach( var (macro, value) in Macros )
{
sb.Replace( macro, value );
}
return sb.ToString();
}
[Benchmark]
public string RegularExpression()
{
return CreateMacroNameRegex()
.Replace(
TemplateText,
match =>
{
var key = match.Groups[1].Value;
return Macros.TryGetValue( key, out var value ) ? value : match.Value;
}
);
}
[Benchmark( Baseline = true )]
public string StringReplace()
{
var result = TemplateText;
foreach( var (macroName, value) in Macros )
{
result = result.Replace( $"${macroName}$", value, StringComparison.OrdinalIgnoreCase );
}
return result;
}
private class Config: ManualConfig
{
public Config()
{
SummaryStyle = SummaryStyle.Default.WithRatioStyle( RatioStyle.Trend );
}
}
[GeneratedRegex( @"\$([^$]+)\$" )]
private static partial Regex CreateMacroNameRegex();
}
As the results indicate, MacroProcessor demonstrates significantly faster performance and lower memory allocation compared to
the String.Format, CompositeFormat, StringBuilder, Regex, and String Replace implementations. This performance increase
is even more dramatic when using a pooled StringBuilder.

| 2.x Concept / API | 3.0 Replacement | Notes |
|---|---|---|
TemplateEngineOptions | TemplateCompilerOptions | Use constructor overloads for configuration. |
TemplateCompiler compiler = new(); compiler.Compile(text) | TemplateCompiler.Compile(text, macroTable, includes, options) or TemplateCompiler.Compile(text, builder, includes, options) or TemplateCompiler.Compile(text, includes, options) | Static; choose overload based on macro table ownership. |
MacroProcessorBuilder | MacroTableBuilder + MacroTable.CreateValues() | Separation of declaration and values. |
builder.AddStandardMacros() | (Removed) | Standard macros are always available; no declaration required. |
builder.AddMacro("Name", "John") | values.SetValue("Name", "John") | Must declare first (unless using auto-declare overload). |
builder.AddMacro("Rand", _ => ...) | values.SetValue("Rand", span => ...) | Delegate signature uses ReadOnlySpan<char>. |
processor.ProcessMacros(template, writer) | template.ProcessMacros(writer, values) | Extension method on Template; note parameter order. |
processor.GetMacroValue("Name") | values.GetValue("Name") | Value access moved. |
| (No includes feature) | IncludesCollection | New compile-time expansion. |
| (N/A) | Positional values overloads | For static-only high-throughput scenarios. |
This project is licensed under the MIT License. See LICENSE for details.