Typewriter
July 23, 2026 Β· View on GitHub

Generate TypeScript from C#, Everywhere
Keep your frontend and backend in perfect sync. Typewriter turns your C# models, Web API controllers, and SignalR hubs into fully-typed TypeScript β automatically, on every change.
This repository is a ground-up reimplementation of the classic Typewriter Visual Studio extension as a cross-platform toolchain: a standalone CLI with watch mode, a Language Server, a modern Visual Studio 2026 extension, a VS Code extension, and a JetBrains Rider plugin β all sharing one Roslyn-powered engine.
π Table of Contents
- Typewriter
- Generate TypeScript from C#, Everywhere
- π Table of Contents
- β¨ Why Typewriter?
- π Why This Version Beats the Classic VS Extension
- π§ Template IntelliSense: What Changed and Where It Works
- π¦ What Is in the Box
- π¦ Quick Start (5 Minutes to Generated Code)
- π CLI Reference
- βοΈ Configuration:
typewriter.json - π Template Authoring
- π Template Settings API
- π§© Editor Integrations
- π§ͺ Samples
- π Migrating from the Original Typewriter
- ποΈ Architecture
- π¨ Building from Source
- πΊ Project Status and Roadmap
- Changelog
- π€ Contributing
- π Issues and Support
- π Acknowledgments
β¨ Why Typewriter?
- π Auto-sync β regenerate TypeScript the moment C# files or templates change (
typewriter watch) - π§ Roslyn-powered β real compiler metadata: records, nullable reference types, generics, tuples, attributes, XML docs
- π₯οΈ Editor-independent β works headless in CI, in Visual Studio 2026, in VS Code, in JetBrains Rider, or via any LSP client
- π― Type safety end to end β eliminate hand-written DTO drift between backend and frontend
- π Powerful
.tsttemplates β filters, lambdas, shared local/remote helpers, and compiled C# helper methods with#rNuGet references - π§© Legacy compatible β runs the original Typewriter template dialect, validated against real-world recipes
- π
Configurable temporal types β map
DateTime,DateOnly,TimeOnly, NodaTime types,Guid, anddecimalto the TypeScript/runtime types your frontend actually uses - π¦ CI-friendly β JSON output, dry-run, deterministic exit codes,
--fail-on-warning - β‘ Fast feedback β live diagnostics, completion, hover, and semantic highlighting for
.tstfiles
π Why This Version Beats the Classic VS Extension
The previous AdaskoTheBeAsT/Typewriter fork is a mature Visual Studio extension. This repository keeps the same .tst template spirit, then turns Typewriter into a modern, scriptable, multi-editor toolchain.
| Area | Classic Typewriter fork | This version |
|---|---|---|
| Runtime model | Visual Studio extension centered around VS events, DTE/COM, and VS project integration | Editor-independent engine plus thin adapters for CLI, LSP, VS Code, Visual Studio, and Rider |
| Automation | Best inside Visual Studio, difficult to run consistently in headless builds | typewriter generate, validate, watch, and list-templates work locally and in CI |
| Configuration | Mostly per-template Settings and Visual Studio options | Repository-local typewriter.json with discovery, formatting, encoding, nullability, naming, dry-run, and warning policy |
| CI behavior | No first-class JSON contract or deterministic command exit model | JSON/text output, dry-run, deterministic exit codes, and --fail-on-warning |
| Editor experience | Visual Studio editor support | Language Server diagnostics, completion, hover, go-to-definition, and semantic tokens, reused by VS Code and any LSP client |
| IDE coverage | Visual Studio 2022 focused | CLI, VS Code, Visual Studio 2026, JetBrains Rider, and any LSP-capable editor |
| Project loading | Visual Studio-oriented solution/project context | Buildalyzer and Roslyn loading for solutions, projects, folders, target frameworks, references, and multi-project workspaces |
| File safety | Generated file behavior depends on the VS extension workflow | Planned writes block paths outside the workspace, refuse non-Typewriter overwrites, skip unchanged files, and warn on duplicate outputs |
| Template runtime | Classic helpers and settings | Classic helpers plus #load, NuGet #r, settings.Log diagnostics, Template(Settings, File), OnRenderComplete, parent/current helper parameters, and richer stack traces |
| Type mapping | Mostly template-authored conventions | Configurable date-time, date-only, time-only, NodaTime, Guid, and decimal mappings with matching default initializers |
| Metadata depth | Classic type/member surface | Structs, indexers, member initializer $Value, preserved XML doc inline elements, and JSDoc formatting helpers |
| Performance | Event-driven generation per IDE workflow | Persistent generation, scoped discovery, metadata indexes, cached templates, glob matchers, type mappings, and loaded dependencies |
| Packaging | VSIX releases | NuGet tool packages, language-server package, VSIX, VS Code VSIX, and Rider plugin artifacts from CI |
New goodies at a glance
- Run it anywhere: generate from a terminal, build server, VS Code, Visual Studio, Rider, or another LSP editor.
- Watch mode without IDE magic:
typewriter watchregenerates when configured input file extensions change. - Safer generated output:
TW0005blocks escaping the workspace,TW0006protects hand-written files, andTW0008exposes duplicate output paths. - Better large-template ergonomics:
Template(Settings settings, File file)lets templates count and pre-filter types before rendering, whileOnRenderComplete(File file)supports final summary logging. - Shared helper files:
#load "shared-helpers.cs"or#load "https://raw.githubusercontent.com/..."keeps large templates maintainable and reusable. - Configurable output style: line endings, UTF-8 BOM, indentation, trailing whitespace, final newline, quote style, strict null generation, and file naming are all configuration-driven.
- Better diagnostics: template logs surface as
TW0007, helper failures include template method/line details, and editors show diagnostics live. - Post-4.0 metadata upgrades: structs, indexers, property/field initializer
$Value, cyclic-reference handling, transitive project references, source generators, and full .NET Framework projects are covered. - Post-4.0 documentation upgrades: XML doc-comment inline tags are preserved, and
Typewriter.Extensions.Documentationcan convert raw XML docs to TypeScript JSDoc. - Post-4.0 performance upgrades: generation reuses persistent processes and caches parsed templates, metadata indexes, glob matchers, file contents, type mappings, and loaded dependencies.
- Post-4.0 type mapping upgrades: configurable
DateTime/DateOnly/TimeOnly, NodaTime,Guid, anddecimalmappings, with default initializers and date-interceptor guidance for runtime string conversion. - Post-4.0 reliability fixes: primitive collection imports, closed generic arguments, dictionary key types, duplicate assembly identities, Buildalyzer diagnostics, and editor generate-on-save edge cases have regression coverage.
- Modern C# metadata: Roslyn metadata covers records, nullable reference types, generics, tuples, doc comments, attributes, static constants, static readonly fields, events, delegates, nested types, and Web API helpers.
- Web API hardening: route helpers ignore named-only HTTP attribute arguments, honor
Template = "...", and safely encode nullable string/date query values. - Recipe-backed compatibility: real Angular/React recipes from
NetCoreTypewriterRecipesare snapshot-tested so old-template support improves against production templates, not toy examples.
π§ Template IntelliSense: What Changed and Where It Works
The classic VS extension offered basic completion inside .tst files. This version moves all template intelligence into the shared Language Server, then layers real compiler services on top. Concretely, editing a .tst file now gives you:
- Documented template members. Completion and hover for
$Classes,$Properties,$BaseClass,$Fields,$DocComment,$IsNullable, filters, and the rest of the template dialect include a description of what each member returns, so you no longer need to guess what$BaseClassor$Valuemeans for the current context. - Real Roslyn IntelliSense inside
${ ... }C# helper blocks. Helper code is projected into a virtual C# document analyzed by an in-process Roslyn workspace: member completion, hover, and go-to-definition work against the actualTypewriter.CodeModelAPI. The whole code model is XML-documented, so hovering.BaseClasson aClassexplains "the direct base class this class inherits from, or null when the class inherits only from object" instead of showing a bare signature. - Workspace-aware completions. Type names, properties, methods, constants, and enum values from your loaded C# projects are offered while writing template blocks and lambda filters, with details such as
Property UserModel.CreatedAt: DateTime. - Forwarding to full language services (VS Code). Through virtual documents (
typewriter/embeddedDocument,typewriter/embeddedPosition,typewriter/templateRangeLSP requests), C# helper blocks are forwarded to the installed C# extension and TypeScript output regions to the built-in TypeScript service, and results are mapped back to.tstpositions. Controlled bytypewriter.embeddedLanguages.forwarding("auto"by default,"off"to keep only the built-in Typewriter IntelliSense). - Live everything. Diagnostics, semantic highlighting for Typewriter/C#/TypeScript regions, go-to-definition into your C# sources, and go-to-generated-file all update as you type, backed by the same engine that generates the output.
IDE support matrix
| Capability | π VS Code | π£ Visual Studio 2026 | π§ Rider |
|---|---|---|---|
Template member completion, hover, and docs ($BaseClass, ...) | β | β (LSP client) | β (LSP client) |
Roslyn IntelliSense in ${ ... } C# helper blocks (built-in) | β | β | β (LSP client) |
| Project-aware IntelliSense (your types in helper blocks) | β | β | β (LSP client) |
| Forwarding to installed C# / TypeScript language services | β | π§ | π§ |
| Semantic highlighting of template / C# / TypeScript regions | β | β (classification) | β (LSP) |
| Live diagnostics in the editor | β | β (Error List) | β (LSP) |
| Generate / validate on save | β | β | β |
Will this work in VS, VS Code, and Rider? Yes. All three IDEs now share the same language server, so template member IntelliSense (documented completions and hover for $BaseClass, $Properties, ...), real Roslyn IntelliSense inside ${ ... } C# helper blocks (with XML doc tooltips and project-aware type completions), live diagnostics, and go-to-definition work identically in VS Code, Visual Studio 2026, and Rider. VS Code additionally forwards to the installed C# extension and built-in TypeScript service through virtual documents (typewriter.embeddedLanguages.forwarding), which is VS Code client-side code that could be replicated in VS and Rider later. Rider enables the LSP client via the built-in IntelliJ Platform LSP API; toggle it in Settings under Typewriter.
π¦ What Is in the Box
| Component | Path | What it does |
|---|---|---|
| π οΈ Typewriter CLI | src/Typewriter.Cli | init, generate, validate, watch, list-templates β scriptable and CI-ready |
| βοΈ Engine | src/Typewriter.Engine | Template parsing, rendering, compiled C# helpers, output planning and writing |
| π¬ Roslyn metadata | src/Typewriter.Roslyn | Extracts classes, records, interfaces, enums, delegates, members, attributes, doc comments |
| ποΈ Buildalyzer bridge | src/Typewriter.Buildalyzer | Loads solutions/projects and resolves references without Visual Studio |
| π Language Server | src/Typewriter.LanguageServer | LSP: live diagnostics, completion, hover, go-to-definition, semantic tokens |
| π VS Code extension | vscode/ | .tst language support, commands, Problems integration, LSP client |
| π£ Visual Studio 2026 extension | src/Typewriter.VisualStudio | SDK-style VSIX for AMD64 and ARM64: generate on save, Error List diagnostics, Output window logging |
| π§ JetBrains Rider plugin | rider/ | IntelliJ frontend plugin: .tst highlighting, Tools menu actions, settings, save-time CLI generation |
| π Abstractions | src/Typewriter.Abstractions | Shared contracts: configuration, diagnostics, metadata, generation results |
π¦ Quick Start (5 Minutes to Generated Code)
1οΈβ£ Choose your IDE plugin or dotnet tool
Start with the plugin for the IDE you use. Download editor packages from the GitHub Tags tab: choose a tag, expand Assets, then install the matching package.
| IDE | Download | Install |
|---|---|---|
| VS Code | typewriter-vscode-<version>.vsix | Run code --install-extension typewriter-vscode-<version>.vsix, or use Extensions β ... β Install from VSIX... |
| Visual Studio 2026 | Typewriter.VisualStudio-<version>.vsix | Double-click the VSIX, or open it with the Visual Studio VSIX installer, then restart Visual Studio |
| JetBrains Rider | Typewriter-Rider-<version>.zip | Use Settings/Preferences β Plugins β gear menu β Install Plugin from Disk..., select the ZIP, then restart Rider |
For Visual Studio, uninstall the old Typewriter64 extension before installing this VSIX. Only one Typewriter add-in should be installed at a time.
The CLI and language server are also published to NuGet as dotnet tools:
dotnet tool install --global AdaskoTheBeAsT.Typewriter.Cli
dotnet tool install --global AdaskoTheBeAsT.Typewriter.LanguageServer
typewriter --help
2οΈβ£ Initialize your workspace
typewriter init --workspace path/to/your/solution
# created: path/to/your/solution/typewriter.json
3οΈβ£ Write your first template
Add Models.tst next to your C# project:
$Classes(*Model)[
export class $Name {
constructor($Properties[public $name: $Type][, ]) {
}
}
]
4οΈβ£ Add a C# model
namespace MyApp.Models;
public class UserModel
{
public int Id { get; set; }
public string Name { get; set; }
public string? Email { get; set; }
public DateTime CreatedAt { get; set; }
}
5οΈβ£ Generate β¨
typewriter generate --workspace path/to/your/solution
Generated UserModel.ts:
export class UserModel {
constructor(
public id: number,
public name: string,
public email: string | null,
public createdAt: Date
) {
}
}
π‘ Pro tip: run
typewriter watchonce and forget about it β every C# or template save regenerates the affected TypeScript.
π CLI Reference
typewriter [command] [options]
Commands
| Command | Description |
|---|---|
init | π Create a typewriter.json with default values (--force to overwrite) |
generate | π Generate TypeScript files from templates (default command) |
validate | β Parse templates and load metadata without writing files |
watch | π Watch configured input extensions and regenerate on change (debounced) |
list-templates | π List the .tst templates discovered for the workspace |
Options
| Option | Description |
|---|---|
--workspace <path> | Solution, project, or folder to operate on |
--project <path> | Specific C# project to read |
--template <path> | A single template file or template directory |
--template-search-path <path> | Restrict template discovery without changing workspace or project context |
--framework <tfm> | Target framework for metadata loading (e.g. net10.0) |
--all-projects | Generate for every project in a multi-project workspace |
--output text|json | Output format (use json in CI and tooling) |
--dry-run | Render everything, write nothing |
--diff | Include unified diffs, including line-ending and final-newline changes |
--fail-on-warning | Non-zero exit code when warnings are emitted |
Exit codes
| Code | Meaning |
|---|---|
0 | π’ Success |
1 | π΄ Generation failed (errors, or warnings with --fail-on-warning) |
2 | π Invalid command-line arguments |
3 | π Project load failed (TW0003) |
4 | π Template parse error (TW0002) |
Diagnostic codes
| Code | Meaning |
|---|---|
TW0001 | Unknown template member |
TW0002 | Template parse error |
TW0003 | Project load failed |
TW0004 | C# compilation diagnostic surfaced from Roslyn |
TW0005 | Output path escapes the workspace (blocked for safety) |
TW0006 | Output would overwrite a file not generated by Typewriter (blocked) |
TW0007 | Template log message (settings.Log) surfaced as a diagnostic |
TW0008 | Duplicate generated output path warning; generation continues and the last render wins unless --fail-on-warning is enabled |
TW0009 | Project passed to Settings.IncludeProject was not found in the workspace |
βοΈ Configuration: typewriter.json
Generated by typewriter init. The generated file includes a $schema reference for editor validation and autocomplete. All values below are the defaults:
{
"$schema": "https://raw.githubusercontent.com/AdaskoTheBeAsT/Typewriter/master/typewriter.schema.json",
"templates": ["**/*.tst"],
"exclude": ["**/bin/**", "**/obj/**", "**/node_modules/**"],
"inputExtensions": [".cs", ".csproj", ".json", ".props", ".sln", ".slnx", ".targets", ".tst"],
"defaultTargetFramework": null,
"output": {
"newline": "lf",
"encoding": "utf-8",
"writeOnlyWhenChanged": true,
"dryRun": false,
"fileNameConvention": "preserve",
"strictNull": true,
"indentStyle": "preserve",
"indentSize": 4,
"insertFinalNewline": false,
"trimTrailingWhitespace": false,
"quoteStyle": "double",
"dateType": "Date",
"dateInitializer": "new Date()",
"dateOnlyType": "Date",
"dateOnlyInitializer": "new Date()",
"timeOnlyType": "string",
"timeOnlyInitializer": "\"00:00:00\"",
"guidType": "string",
"guidInitializer": "auto",
"decimalType": "number",
"decimalInitializer": "auto"
},
"diagnostics": {
"failOnWarning": false
}
}
Top-level settings
| Key | Default | Description |
|---|---|---|
templates | ["**/*.tst"] | Glob patterns used to discover templates |
exclude | bin, obj, node_modules globs | Glob patterns excluded from discovery |
inputExtensions | .cs, .csproj, .json, .props, .sln, .slnx, .targets, .tst | File extensions that trigger typewriter watch and editor generate-on-save. Dot is optional |
defaultTargetFramework | null | TFM used when a project multi-targets (e.g. net10.0) |
output settings
| Key | Default | Description |
|---|---|---|
newline | "lf" | lf or crlf line endings in generated files |
encoding | "utf-8" | utf-8 (no BOM) or utf-8-bom β οΈ the original Typewriter emitted a BOM by default β set utf-8-bom for byte-identical output |
writeOnlyWhenChanged | true | Skip disk writes when content is unchanged (keeps file watchers and HMR calm) |
dryRun | false | Render without writing files |
fileNameConvention | "preserve" | Output file naming: preserve, kebab, pascal, camel, snake |
strictNull | true | Render nullable types as T | null; templates can override via settings.DisableStrictNullGeneration() |
indentStyle | "preserve" | Re-indent generated output: preserve, space, or tab (the rendered indent unit is detected automatically) |
indentSize | 4 | Target indent width used when indentStyle is space |
insertFinalNewline | false | Ensure generated files end with a single trailing newline |
trimTrailingWhitespace | false | Strip trailing spaces and tabs from every generated line |
quoteStyle | "double" | Default string literal character for generated defaults: double, single, or backtick; settings.UseStringLiteralCharacter(...) in a template wins |
dateLibrary | "legacy" | Coherent semantic profile: legacy, nativeDate, temporal, moment, luxon, dateFns, dayJs, or jsJoda; settings.UseDateLibrary(...) wins |
dateType | "Date" | TypeScript type used for DateTime, DateTimeOffset, and date-time-like NodaTime values; settings.UseDateType(...) wins |
dateInitializer | "new Date()" | TypeScript initializer used for non-null date-time defaults; settings.UseDateInitializer(...) wins |
dateOnlyType | "Date" | TypeScript type used for DateOnly, NodaTime.LocalDate, and NodaTime.OffsetDate; settings.UseDateOnlyType(...) wins |
dateOnlyInitializer | "new Date()" | TypeScript initializer used for non-null date-only defaults; settings.UseDateOnlyInitializer(...) wins |
timeOnlyType | "string" | TypeScript type used for TimeOnly, NodaTime.LocalTime, and NodaTime.OffsetTime; settings.UseTimeOnlyType(...) wins |
timeOnlyInitializer | "\"00:00:00\"" | TypeScript initializer used for non-null time-only defaults; default literal follows quoteStyle, and settings.UseTimeOnlyInitializer(...) wins |
guidType | "string" | TypeScript type used for C# Guid; set to uuid, UUID, or a branded alias with settings.UseGuidType(...) or config |
guidInitializer | "auto" | Non-null Guid initializer; auto emits the empty GUID string, or new Uint8Array(16) for Uint8Array; settings.UseGuidInitializer(...) wins |
decimalType | "number" | TypeScript type used for C# decimal; settings.UseDecimalType(...) in a template wins |
decimalInitializer | "auto" | Non-null decimal initializer; auto emits 0, or new <decimalType>(0) for a custom type; settings.UseDecimalInitializer(...) wins |
Date and time mapping
DateTime, DateTimeOffset, and date-time-like NodaTime values generate Date by default, and non-null defaults generate new Date(). DateOnly, NodaTime.LocalDate, and NodaTime.OffsetDate also default to Date/new Date(). TimeOnly, NodaTime.LocalTime, and NodaTime.OffsetTime default to string/"00:00:00". Nullable value types keep the normal strict-null behavior, so DateTime? and Nullable<DateTime> generate Date | null when output.strictNull is true.
public sealed record AuditDto(
DateTime CreatedAt,
DateTime? ApprovedAt,
DateTimeOffset SubmittedAt);
export interface AuditDto {
createdAt: Date;
approvedAt: Date | null;
submittedAt: Date;
}
Typewriter 4.8.0 can select a coherent semantic profile instead of configuring each type and initializer separately:
{
"output": {
"dateLibrary": "temporal"
}
}
${
Template(Settings settings)
{
settings.UseDateLibrary(DateLibrary.JsJoda);
}
}
All supported values are:
output.dateLibrary value | Template API equivalent | Profile |
|---|---|---|
"legacy" | settings.UseDateLibrary(DateLibrary.Legacy) | Preserves the legacy dateType, dateOnlyType, timeOnlyType, and initializer settings |
"nativeDate" | settings.UseDateLibrary(DateLibrary.NativeDate) | Native JavaScript Date where it can represent the semantic type; otherwise string |
"temporal" | settings.UseDateLibrary(DateLibrary.Temporal) | Temporal types from @js-temporal/polyfill |
"moment" | settings.UseDateLibrary(DateLibrary.Moment) | Moment and Moment Duration types |
"luxon" | settings.UseDateLibrary(DateLibrary.Luxon) | Luxon DateTime and Duration types |
"dateFns" | settings.UseDateLibrary(DateLibrary.DateFns) | Native Date plus the date-fns Duration type |
"dayJs" | settings.UseDateLibrary(DateLibrary.DayJs) | Day.js types with its duration plugin |
"jsJoda" | settings.UseDateLibrary(DateLibrary.JsJoda) | js-joda semantic date/time types plus @js-joda/timezone |
The JSON values above show the canonical camel-case spellings accepted by output.dateLibrary. legacy is the default.
The profile supplies semantic TypeScript types, non-null initializers, and settings.DateLibraryImportsGeneration. Templates remain responsible for emitting the import text at the appropriate location. Configuration-level dateLibrary takes precedence over the low-level date strings. Inside a template, call UseDateLibrary(...) first and then call a low-level UseDateType, UseDateOnlyType, or UseTimeOnlyType method when an intentional override is needed.
System and NodaTime profile matrix
| C# type | Semantic kind | Native Date | Temporal | Moment | Luxon | date-fns | Day.js | js-joda |
|---|---|---|---|---|---|---|---|---|
DateTime | plain date-time | Date | Temporal.PlainDateTime | moment.Moment | DateTime | Date | Dayjs | LocalDateTime |
DateTimeOffset | instant | Date | Temporal.Instant | moment.Moment | DateTime | Date | Dayjs | Instant |
DateOnly | plain date | Date | Temporal.PlainDate | moment.Moment | DateTime | Date | Dayjs | LocalDate |
TimeOnly | plain time | string | Temporal.PlainTime | string | DateTime | string | string | LocalTime |
TimeSpan | elapsed duration | string | Temporal.Duration | moment.Duration | Duration | Duration | ReturnType<typeof dayjs.duration> | Duration |
NodaTime.Instant | instant | Date | Temporal.Instant | moment.Moment | DateTime | Date | Dayjs | Instant |
NodaTime.LocalDate | plain date | Date | Temporal.PlainDate | moment.Moment | DateTime | Date | Dayjs | LocalDate |
NodaTime.OffsetDate | plain date | Date | Temporal.PlainDate | moment.Moment | DateTime | Date | Dayjs | LocalDate |
NodaTime.LocalTime | plain time | string | Temporal.PlainTime | string | DateTime | string | string | LocalTime |
NodaTime.OffsetTime | plain time | string | Temporal.PlainTime | string | DateTime | string | string | LocalTime |
NodaTime.LocalDateTime | plain date-time | Date | Temporal.PlainDateTime | moment.Moment | DateTime | Date | Dayjs | LocalDateTime |
NodaTime.OffsetDateTime | instant | Date | Temporal.Instant | moment.Moment | DateTime | Date | Dayjs | Instant |
NodaTime.ZonedDateTime | zoned date-time | string | Temporal.ZonedDateTime | string | DateTime | string | string | ZonedDateTime |
NodaTime.Duration | elapsed duration | string | Temporal.Duration | moment.Duration | Duration | Duration | ReturnType<typeof dayjs.duration> | Duration |
NodaTime.Period | calendar period | string | Temporal.Duration | moment.Duration | Duration | Duration | ReturnType<typeof dayjs.duration> | Period |
NodaTime.YearMonth | plain year-month | string | Temporal.PlainYearMonth | string | DateTime | string | string | YearMonth |
NodaTime.AnnualDate | plain month-day | string | Temporal.PlainMonthDay | string | DateTime | string | string | MonthDay |
Duration means elapsed time, while Period means calendar-relative units such as years and months. They remain separate schema kinds even when a frontend library represents both with the same runtime class. A string entry means that the selected library backend cannot faithfully represent that semantic kind.
DateTime is inherently ambiguous, so its default is plain date-time. Override a member when the domain says otherwise:
dotnet add package AdaskoTheBeAsT.Typewriter.Annotations
using AdaskoTheBeAsT.Typewriter.Annotations;
public sealed record AuditDto(
[property: FrontendRuntimeType(FrontendRuntimeType.Instant)]
DateTime CreatedAt);
The neutral annotation values include Instant, PlainDate, PlainTime, PlainDateTime, ZonedDateTime, Duration, Period, PlainYearMonth, and PlainMonthDay. Existing Temporal* names are aliases. The annotation selects semantics, not a JavaScript library; one dateLibrary and one matching runtime backend apply to the generated client.
The AdaskoTheBeAsT.Typewriter.Annotations package also includes source-level attributes that recipes and templates recognise by simple name:
| Attribute | Applies to | Purpose |
|---|---|---|
GenerateFrontendTypeAttribute | Class, interface, enum, method | Marks a type or method for inclusion in frontend generation; optional DllName routes the type to a specific output assembly |
FrontendRuntimeTypeAttribute | Property, field, parameter | Overrides the semantic runtime type for a single member |
FrontendNoTransformAttribute | Property, field, parameter | Emits the value as a plain passthrough, skipping runtime transformation |
AsStringAttribute | Enum | Emits enum values as strings instead of numbers |
LabelForEnumAttribute | Field (enum member) | Attaches a human-readable label to an enum value |
CustomNameAttribute | Method | Overrides the generated frontend method name |
Typewriter matches these by simple name, so user-defined attributes with the same name in any namespace continue to work without the package.
To use another date library in generated types, configure the date-time, date-only, and time-only type/initializer pairs, or call the matching Settings methods from a template:
{
"output": {
"dateType": "Dayjs",
"dateInitializer": "dayjs()",
"dateOnlyType": "Dayjs",
"dateOnlyInitializer": "dayjs()",
"timeOnlyType": "string",
"timeOnlyInitializer": "\"00:00:00\""
}
}
${
Template(Settings settings)
{
settings.UseDateType("DateTime"); // for Luxon
settings.UseDateInitializer("DateTime.now()");
settings.UseDateOnlyType("DateTime");
settings.UseDateOnlyInitializer("DateTime.now().startOf('day')");
settings.UseTimeOnlyType("string");
settings.UseTimeOnlyInitializer("\"00:00:00\"");
}
}
If the selected date type is not global, update your template to emit the required TypeScript import, for example import type { Dayjs } from 'dayjs';.
If your API returns ISO strings for DateTime, DateTimeOffset, DateOnly, or NodaTime values, pair generated models with date-interceptors for runtime conversion. This is required when generated TypeScript uses Date, Day.js, Moment.js, Luxon, js-joda, Temporal, or another runtime date type, because JSON still arrives as strings and must be translated to the corresponding object type.
| Target runtime | Date-time config | Date-only config | Time-only config | Type imports | Converter |
|---|---|---|---|---|---|
Native Date | dateType: "Date", dateInitializer: "new Date()" | dateOnlyType: "Date", dateOnlyInitializer: "new Date()" | timeOnlyType: "string", timeOnlyInitializer: "\"00:00:00\"" | none | @adaskothebeast/hierarchical-convert-to-date |
| date-fns | same as native Date | same as native Date | ISO string | none | @adaskothebeast/hierarchical-convert-to-date-fns |
| Day.js | Dayjs, dayjs() | Dayjs, dayjs() | ISO string | import type { Dayjs } from 'dayjs'; | @adaskothebeast/hierarchical-convert-to-dayjs |
| Moment.js | Moment, moment() | Moment, moment() | ISO string | import type { Moment } from 'moment'; | @adaskothebeast/hierarchical-convert-to-moment |
| Luxon | DateTime, DateTime.now() | DateTime, DateTime.now().startOf('day') | ISO string | import type { DateTime } from 'luxon'; | @adaskothebeast/hierarchical-convert-to-luxon |
| js-joda | ZonedDateTime, ZonedDateTime.now() or LocalDateTime, LocalDateTime.now() | LocalDate, LocalDate.now() | LocalTime, LocalTime.now() | import type { LocalDate, LocalTime, ZonedDateTime } from '@js-joda/core'; | @adaskothebeast/hierarchical-convert-to-js-joda |
| Temporal | Temporal.Instant, Temporal.Now.instant() or Temporal.PlainDateTime, Temporal.Now.plainDateTimeISO() | Temporal.PlainDate, Temporal.Now.plainDateISO() | Temporal.PlainTime, Temporal.Now.plainTimeISO() | import { Temporal } from '@js-temporal/polyfill'; | @adaskothebeast/hierarchical-convert-to-temporal |
# Native Date
npm install @adaskothebeast/angular-date-http-interceptor @adaskothebeast/hierarchical-convert-to-date
# date-fns
npm install @adaskothebeast/angular-date-http-interceptor @adaskothebeast/hierarchical-convert-to-date-fns date-fns
# Day.js
npm install @adaskothebeast/angular-date-http-interceptor @adaskothebeast/hierarchical-convert-to-dayjs dayjs
# Moment.js
npm install @adaskothebeast/angular-date-http-interceptor @adaskothebeast/hierarchical-convert-to-moment moment
# Luxon
npm install @adaskothebeast/angular-date-http-interceptor @adaskothebeast/hierarchical-convert-to-luxon luxon
# js-joda, useful for NodaTime-style payloads
npm install @adaskothebeast/angular-date-http-interceptor @adaskothebeast/hierarchical-convert-to-js-joda @js-joda/core @js-joda/timezone
# Temporal
npm install @adaskothebeast/angular-date-http-interceptor @adaskothebeast/hierarchical-convert-to-temporal @js-temporal/polyfill
import { NgModule } from '@angular/core';
import {
AngularDateHttpInterceptorModule,
HIERARCHICAL_DATE_ADJUST_FUNCTION,
} from '@adaskothebeast/angular-date-http-interceptor';
import { hierarchicalConvertToDate } from '@adaskothebeast/hierarchical-convert-to-date';
@NgModule({
imports: [AngularDateHttpInterceptorModule],
providers: [
{ provide: HIERARCHICAL_DATE_ADJUST_FUNCTION, useValue: hierarchicalConvertToDate },
],
})
export class AppModule {
}
Swap the converter import to match your target:
import { hierarchicalConvertToDateFns } from '@adaskothebeast/hierarchical-convert-to-date-fns';
import { hierarchicalConvertToDayjs } from '@adaskothebeast/hierarchical-convert-to-dayjs';
import { hierarchicalConvertToMoment } from '@adaskothebeast/hierarchical-convert-to-moment';
import { hierarchicalConvertToLuxon } from '@adaskothebeast/hierarchical-convert-to-luxon';
import { hierarchicalConvertToJsJoda } from '@adaskothebeast/hierarchical-convert-to-js-joda';
Example generated model shapes:
// Native Date or date-fns
createdAt: Date;
approvedAt: Date | null;
// Day.js
createdAt: Dayjs;
approvedAt: Dayjs | null;
// Moment.js
createdAt: Moment;
approvedAt: Moment | null;
// Luxon
createdAt: DateTime;
approvedAt: DateTime | null;
// js-joda
createdAt: ZonedDateTime;
approvedAt: ZonedDateTime | null;
Recommended semantic mappings:
| C# or NodaTime type | Recommended TypeScript shape | Notes |
|---|---|---|
DateTime, DateTimeOffset, NodaTime.Instant, NodaTime.OffsetDateTime, NodaTime.ZonedDateTime | Date, Temporal.Instant, Luxon DateTime, Day.js Dayjs, or js-joda Instant/ZonedDateTime | Use a real date-time object when clients compare, format, or calculate with the value. Use date-interceptors to convert JSON strings at runtime. |
DateOnly, NodaTime.LocalDate, NodaTime.OffsetDate | ISO string, Temporal.PlainDate, or js-joda LocalDate | Avoid native Date for pure dates if timezone shifts matter. |
TimeOnly, NodaTime.LocalTime, NodaTime.OffsetTime | ISO string, Temporal.PlainTime, or js-joda LocalTime | JavaScript has no native time-only type. |
NodaTime.LocalDateTime | Temporal.PlainDateTime, Luxon DateTime, js-joda LocalDateTime, or ISO string | Use output.dateType for this local date-time type. |
TimeSpan, NodaTime.Duration | ISO duration string, Temporal.Duration, js-joda Duration, or a numeric convention such as milliseconds | Pick one wire format and document it. Duration means elapsed time. |
NodaTime.Period | ISO period string, Temporal.Duration, js-joda Period, or date-fns Duration | Period is calendar-based, such as 1 month and 3 days, so keep it distinct from elapsed Duration. |
For date types that need an import, emit the import only when the current class actually has date fields or properties:
${
static string dateTypeImport = string.Empty;
Template(Settings settings)
{
settings.UseDateType("Dayjs");
settings.UseDateInitializer("dayjs()");
dateTypeImport = settings.DateTypeGeneration switch
{
"Dayjs" => "import type { Dayjs } from 'dayjs';",
"Moment" => "import type { Moment } from 'moment';",
"DateTime" => "import type { DateTime } from 'luxon';",
"ZonedDateTime" => "import type { ZonedDateTime } from '@js-joda/core';",
_ => string.Empty, // Date and date-fns use native Date and need no import.
};
}
string DateTypeImport(Class c)
{
return UsesDate(c) && !string.IsNullOrEmpty(dateTypeImport)
? dateTypeImport + Environment.NewLine
: string.Empty;
}
bool UsesDate(Class c)
{
return c.Properties.Any(p => UsesDate(p.Type))
|| c.Fields.Any(f => UsesDate(f.Type))
|| c.StaticReadOnlyFields.Any(f => UsesDate(f.Type));
}
bool UsesDate(Type type)
{
return type.IsDate
|| (type.ElementType != null && UsesDate(type.ElementType))
|| type.TypeArguments.Any(UsesDate);
}
}
$Classes[
$DateTypeImport
export interface I$Name {
$Properties[
$name: $Type;]
}
]
With settings.UseDateType("Dayjs"), a class with DateTime CreatedAt generates:
import type { Dayjs } from 'dayjs';
export interface IAuditDto {
createdAt: Dayjs;
}
For NodaTime-specific DTOs, map those C# types in your template to the TypeScript date type you use, then let the interceptor convert response values:
${
string TypeName(Property property)
{
return property.Type.FullName switch
{
"NodaTime.Instant" => "Instant",
"NodaTime.ZonedDateTime" => "ZonedDateTime",
"NodaTime.OffsetDateTime" => "OffsetDateTime",
"NodaTime.LocalDateTime" => "LocalDateTime",
"NodaTime.LocalDate" => "LocalDate",
"NodaTime.LocalTime" => "LocalTime",
"NodaTime.Duration" => "Duration",
"NodaTime.Period" => "Period",
_ => property.Type.Name,
};
}
}
export interface $Name {
$Properties[
$name: $TypeName;]
}
Guid mapping
C# Guid maps to TypeScript string by default. If your frontend uses a stronger UUID alias, configure output.guidType or call settings.UseGuidType(...) from a template:
{
"output": {
"guidType": "uuid"
}
}
${
Template(Settings settings)
{
settings.UseGuidType("uuid");
}
}
TypeScript itself does not have a built-in UUID type. The npm uuid package provides UUID generators, parsers, validators, and TypeScript declarations, but its standard generators return strings rather than introducing a lowercase uuid domain type. Use string, emit type uuid = string;, or import a project-specific branded type. With guidInitializer: "auto", $Type[$Default] for Guid continues to generate the empty GUID string.
For schema-aware clients that hydrate UUID strings into 16-byte values, use Uint8Array:
{
"output": {
"guidType": "Uint8Array",
"guidInitializer": "auto"
}
}
${
Template(Settings settings)
{
settings
.UseGuidType("Uint8Array")
.UseGuidInitializer("auto");
}
}
With Uint8Array, the automatic non-null default is new Uint8Array(16). For another UUID class or factory, set guidInitializer to any TypeScript expression. For example, when the template imports { NIL } from the npm uuid package, use "NIL".
Numeric and decimal mapping
Numeric primitives map to TypeScript number by default, including byte, short, int, long, float, double, and decimal.
public sealed record TotalsDto(
int Count,
float Ratio,
double Average,
decimal Amount);
export interface TotalsDto {
count: number;
ratio: number;
average: number;
amount: number;
}
If you need exact decimal arithmetic, configure decimal.js by changing the generated decimal type:
npm install decimal.js
{
"output": {
"decimalType": "Decimal",
"decimalInitializer": "auto"
}
}
${
Template(Settings settings)
{
settings
.UseDecimalType("Decimal")
.UseDecimalInitializer("auto");
}
}
Then emit the decimal.js import only for classes that actually use C# decimal fields or properties:
${
string DecimalImport(Class c)
{
return UsesDecimal(c)
? "import Decimal from 'decimal.js';" + Environment.NewLine
: string.Empty;
}
bool UsesDecimal(Class c)
{
return c.Properties.Any(p => UsesDecimal(p.Type))
|| c.Fields.Any(f => UsesDecimal(f.Type))
|| c.StaticReadOnlyFields.Any(f => UsesDecimal(f.Type));
}
bool UsesDecimal(Type type)
{
return type.FullName == "System.Decimal"
|| (type.ElementType != null && UsesDecimal(type.ElementType))
|| type.TypeArguments.Any(UsesDecimal);
}
}
$Classes[
$DecimalImport
export interface I$Name {
$Properties[
$name: $Type;]
}
]
With settings.UseDecimalType("Decimal"), decimal Amount generates amount: Decimal;. The automatic $Type[$Default] is new Decimal(0); set decimalInitializer or call UseDecimalInitializer(...) when a custom decimal type uses another factory.
For lossless high-precision values, the API must serialize the decimal as a JSON string. A JSON number is parsed into a JavaScript number before Decimal.js sees it, so precision may already be lost. Runtime-schema templates should emit schema.decimal('string') for string-encoded decimals and schema.decimal('number') only when number-wire precision is acceptable. [FrontendRuntimeType(FrontendRuntimeType.Decimal, WireFormat = "string")] can communicate that choice to templates.
diagnostics settings
| Key | Default | Description |
|---|---|---|
failOnWarning | false | Treat warnings as failures (great for CI) |
π Discovery and precedence
- Configuration files are looked up by name, in order:
typewriter.json,typewriter.config.json,.typewriterrc.json - Files in the workspace folder load first, then files in the project folder override them
- Environment variables override file values
- CLI flags (
--framework,--dry-run,--fail-on-warning) win over everything
π± Environment variables
| Variable | Overrides |
|---|---|
TYPEWRITER_INPUT_EXTENSIONS | inputExtensions (comma/semicolon/space list) |
TYPEWRITER_DEFAULT_TARGET_FRAMEWORK | defaultTargetFramework |
TYPEWRITER_OUTPUT_NEWLINE | output.newline |
TYPEWRITER_OUTPUT_DATE_LIBRARY | output.dateLibrary |
TYPEWRITER_OUTPUT_DATE_TYPE | output.dateType |
TYPEWRITER_OUTPUT_DATE_INITIALIZER | output.dateInitializer |
TYPEWRITER_OUTPUT_DATE_ONLY_TYPE | output.dateOnlyType |
TYPEWRITER_OUTPUT_DATE_ONLY_INITIALIZER | output.dateOnlyInitializer |
TYPEWRITER_OUTPUT_TIME_ONLY_TYPE | output.timeOnlyType |
TYPEWRITER_OUTPUT_TIME_ONLY_INITIALIZER | output.timeOnlyInitializer |
TYPEWRITER_OUTPUT_GUID_TYPE | output.guidType |
TYPEWRITER_OUTPUT_GUID_INITIALIZER | output.guidInitializer |
TYPEWRITER_OUTPUT_DECIMAL_TYPE | output.decimalType |
TYPEWRITER_OUTPUT_DECIMAL_INITIALIZER | output.decimalInitializer |
TYPEWRITER_FAIL_ON_WARNING | diagnostics.failOnWarning (true/1/yes) |
π Template Authoring
Templates are .tst files using the original Typewriter dialect β existing templates keep working.
Core syntax
| Construct | Meaning |
|---|---|
$Classes(filter)[...] | Render block for every matching class (also $Records, $Structs, $Interfaces, $Enums, $Delegates) |
$Properties[...][separator] | Iterate members, including indexers (also $Methods, $Parameters, $Fields, $Constants, $Events, $Values) |
$Name, $name, $FullName, $Namespace, $Type | Scalar substitutions (lowercase first letter via $name) |
$$ | Render one literal $, for example $$type outputs $type without treating it as a template member |
(*Model), ([Attribute]), (c => c.IsPublic) | Wildcard, attribute, and lambda filters |
$IsNullable[yes][no] | Conditional true/false blocks |
${ ... } | Compiled C# helper block β write real C# methods used by the template |
#r "nuget: PackageId, 1.2.3" | Reference DLLs or NuGet packages (restored automatically) from helper code |
#load "shared-helpers.cs" | Include shared C# source helper members from a file relative to the current template/helper file |
Struct and indexer templates
Version 4.1.0 adds first-class struct and indexer support. Structs are useful for DTO generation when your serializer can read and write them. Indexers are exposed for template completeness and lookup-style TypeScript APIs, but they are not JSON payload properties.
Use $Structs[...] when value types should be generated separately from classes and records:
$Structs[
export interface $Name {
$Properties[
$name: $Type;]
}
]
You can also filter all available types to structs:
$Types(Struct)[
export type $NameValue = $Properties[$Type][ | ];
]
Indexer properties are included in $Properties[...]. Use $IsIndexer to distinguish them from normal properties, and render indexer parameters through $Parameters[...]:
$Classes[
export interface $NameLookup {
$Properties[
$IsIndexer[
get($Parameters[$name: $Type][, ]): $Type;
][
$name: $Type;
]]
}
]
For this C# type:
public sealed class LocalizedText
{
public string Default { get; init; } = string.Empty;
public string this[string culture] => Default;
}
public readonly struct Money
{
public decimal Amount { get; init; }
public string Currency { get; init; }
}
Templates can now generate both the Money value shape and a typed lookup signature for LocalizedText.
Indexer note:
System.Text.Jsonand Newtonsoft.Json do not deserialize indexers as normal object properties. If the lookup values must round-trip through JSON, expose a real property and generate from that instead:
public sealed class LocalizedTextDto
{
public Dictionary<string, string> Values { get; init; } = [];
}
Then generate the TypeScript dictionary shape from Values, for example Record<string, string>.
Sharing logic between templates: #load vs #r
Use #load when you want to share helper source code between templates. These files are usually .cs files, but they are helper snippets, not standalone C# files with their own namespace and class. Typewriter inserts their contents into each generated template helper class, so put helper members directly in the file:
templates/
_helpers/
names.cs
models.tst
services.tst
// templates/_helpers/names.cs
string ContractName(Class c) => c.Name.EndsWith("Dto") ? c.Name[..^3] : c.Name;
Then load the same helper file from any .tst template that needs it:
${
#load "_helpers/names.cs"
}
$Classes[
export interface $ContractName {
}
]
#load paths are relative to the current .tst file or the helper file that contains the nested #load. Loaded helper files may also contain using, #r, and more #load directives. Since each template is compiled separately, #load shares code, not runtime state.
#load can also download helper source from http or https URLs, which is handy for central template helper files hosted in GitHub:
${
#load "https://raw.githubusercontent.com/owner/repo/v1.2.3/templates/helpers/names.cs"
}
By default, remote helpers are downloaded on each template compilation. Add a positive TimeSpan after the URL to cache a remote helper in memory for the current process, which is useful for watch mode and language-server validation:
${
#load "https://raw.githubusercontent.com/owner/repo/v1.2.3/templates/helpers/names.cs", "00:10:00"
}
The cache is process-local, stores only successful downloads, and applies only to remote http/https loads. For GitHub, use raw file URLs (raw.githubusercontent.com/...), not normal github.com/.../blob/... pages. Prefer immutable commit SHAs or release tags instead of branch names for reproducible builds. Remote helper files are capped at 1 MB and compiled as C# template code, so only load URLs you trust.
Loaded files can contain helper methods directly, which makes them callable from template syntax such as $ContractName. They can also contain nested types such as static class NameHelpers, but those nested static class methods are not discovered as $... template helpers automatically. Use a thin helper method when you want to call a static class from a template:
// templates/_helpers/names.cs
static class NameHelpers
{
public static string ContractName(string name) => name.EndsWith("Dto") ? name[..^3] : name;
}
string ContractName(Class c) => NameHelpers.ContractName(c.Name);
If you want fully testable shared logic, the cleanest pattern is a normal C# class library with unit tests, referenced from templates with #r. Keep Typewriter-specific wrapper methods in .tst files or small #load snippets:
${
#r "../TemplateHelpers/bin/Release/net10.0/TemplateHelpers.dll"
using MyCompany.TemplateHelpers;
string ContractName(Class c) => NameHelpers.ContractName(c.Name);
}
Use #r when helper code needs a compiled assembly reference, not source. It can reference a local .dll or a NuGet package. If your shared logic grows into a normal C# class library with namespaces and classes, compile it and reference it with #r:
${
#r "nuget: Humanizer.Core, 2.14.1"
using Humanizer;
string DisplayName(Class c) => c.Name.Humanize();
}
NuGet references are restored automatically into the normal NuGet package cache. If you use private feeds, place a NuGet.config near the template or workspace; Typewriter searches upward from the template directory.
Compiled C# helpers
${
using Typewriter.VisualStudio;
static ILog log;
Template(Settings settings)
{
settings.UseStringLiteralCharacter('\'');
log = settings.Log;
}
bool IncludeClass(Class c)
{
log.LogInfo($"Processing {c.Name}");
return c.Attributes.Any(a => a.Name == "GenerateFrontendType");
}
string LoudName(Constant constant) => constant.Name.ToUpperInvariant();
}
$Classes($IncludeClass)[$Constants[
export const $LoudName = '$Value';]
]
Helper methods can receive both the parent and current context when invoked from nested collections, for example string Qualified(Class c, Property p). Runtime helper failures include the original template method and line when possible.
For large templates, the template constructor can also receive the current File before the template body renders. Use this for counts and in-memory pre-filtering:
${
private Class[] dtoClasses = Array.Empty<Class>();
Template(Settings settings, File file)
{
settings.Log.LogInfo("Processing {0} classes", file.Classes.Count);
dtoClasses = file.Classes.Where(c => c.Name.EndsWith("Dto")).ToArray();
}
IEnumerable<Class> DtoClasses(File file) => dtoClasses;
}
$DtoClasses[
export interface $Name {
}
]
Shared helpers and render completion hooks
${
#load "shared-helpers.cs"
static ILog log;
Template(Settings settings)
{
log = settings.Log;
}
void OnRenderComplete(File file)
{
log.LogInfo("Rendered {0} classes", file.Classes.Count);
}
}
#load supports nested helper files and reports missing files as TW0002 diagnostics. OnRenderComplete() or OnRenderComplete(File file) runs after template rendering and surfaces hook failures as template diagnostics.
Custom output paths
${
Template(Settings settings)
{
settings.OutputFilenameFactory = file => file.Classes.First().Name + ".generated.ts";
// or render everything into one file:
// settings.SingleFileMode("api-models.ts");
}
}
Web API URL helpers
The built-in Web API helpers parse Route and RoutePrefix attribute values, ignore named-only HTTP attribute arguments such as Name = "ListUsers" or Order = 1, honor Template = "...", and encode nullable string/date query values as encodeURIComponent(value ?? '').
π Production-ready templates: the NetCoreTypewriterRecipes repository contains battle-tested templates for π °οΈ Angular services, βοΈ React models, π polymorphic JSON discriminators (System.Text.Json and Newtonsoft.Json), enums, records, and constants. This engine is snapshot-tested against those recipes.
π Template Settings API
The Settings object passed to the template constructor keeps the original API so old templates compile unchanged. Current wiring status:
| Setting | Status | Notes |
|---|---|---|
OutputExtension | β | Default .ts |
OutputFilenameFactory | β | Per-source fan-out and no-match suppression supported |
OutputDirectory | β | Relative paths resolve against the template location |
SingleFileMode("name.ts") | β | Renders the whole template into one file |
UseStringLiteralCharacter('\'') | β | Affects generated string literals and defaults; defaults from output.quoteStyle in typewriter.json |
UseDateLibrary(DateLibrary.Temporal) | β | Selects one coherent semantic type, initializer, and import profile; inspect DateLibraryImportsGeneration for imports |
UseDateType("Date") | β | Overrides date-time types such as DateTime, DateTimeOffset, and NodaTime Instant |
UseDateInitializer("new Date()") | β | Overrides the generated TypeScript initializer for non-null date-time defaults |
UseDateOnlyType("Date") | β | Overrides date-only types such as DateOnly and NodaTime LocalDate |
UseDateOnlyInitializer("new Date()") | β | Overrides the generated TypeScript initializer for non-null date-only defaults |
UseTimeOnlyType("string") | β | Overrides time-only types such as TimeOnly and NodaTime LocalTime |
UseTimeOnlyInitializer("\"00:00:00\"") | β | Overrides the generated TypeScript initializer for non-null time-only defaults |
UseGuidType("string") | β | Overrides the generated TypeScript type for C# Guid, for example uuid or UUID |
UseGuidInitializer("auto") | β | Overrides non-null Guid defaults; auto understands string and Uint8Array mappings |
UseDecimalType("number") | β | Overrides the generated TypeScript type for C# decimal, for example Decimal from decimal.js |
UseDecimalInitializer("auto") | β | Overrides non-null decimal defaults; auto emits 0 or constructs the configured decimal type |
TemplatePath | β | Full path of the executing template |
Log (ILog) | β | Messages surface as TW0007 diagnostics in CLI output and editors |
IncludeProject(name) | β | Merges the named workspace project (and its references) into the template's code model, in addition to the current project; unknown names raise TW0009 |
IncludeCurrentProject() / IncludeReferencedProjects() / IncludeAllProjects() | β»οΈ | Accepted for compatibility; the current project and its references are always included, --all-projects controls full workspace scope |
DisableStrictNullGeneration() | β | Defaults from output.strictNull in typewriter.json; calling this in the template overrides it |
DisableUtf8BomGeneration() / Utf8BomGeneration | β | Defaults from output.encoding in typewriter.json; the template override wins per file |
PartialRenderingMode | π§ | Accepted but not wired yet |
SolutionFullName | β | Populated from the resolved workspace (solution or folder path) |
SkipAddingGeneratedFilesToProject | β | Obsolete β SDK-style projects include files automatically |
Legend: β working Β· β»οΈ superseded by CLI/config Β· π§ accepted no-op (tracked) Β· β intentionally dropped
π§© Editor Integrations
Installing editor packages
Release builds produce installable editor packages as GitHub release assets:
| Editor | Artifact | Install |
|---|---|---|
| Visual Studio 2026 | Typewriter.VisualStudio-<version>.vsix | Double-click the VSIX, or run it with the Visual Studio VSIX installer, then restart Visual Studio |
| VS Code | typewriter-vscode-<version>.vsix | Run code --install-extension typewriter-vscode-<version>.vsix, or use Extensions β ... β Install from VSIX... |
| JetBrains Rider | Typewriter-Rider-<version>.zip | Use Settings/Preferences β Plugins β gear menu β Install Plugin from Disk..., select the ZIP, then restart Rider |
The editor packages include the Typewriter CLI and language server tools used by the integrations. If you build locally, the same package names are written to artifacts/packages.
π VS Code
Located in vscode/ (extension README).
Commands: Typewriter: Generate Current Template, Generate All Templates, Validate Current Template, Restart Language Server
Settings:
{
"typewriter.cliPath": "",
"typewriter.cliArguments": [],
"typewriter.workspacePath": null,
"typewriter.projectPath": null,
"typewriter.templatePath": null,
"typewriter.framework": null,
"typewriter.allProjects": false,
"typewriter.generateOnSave": true,
"typewriter.validateOnSave": true,
"typewriter.languageServer.enabled": true,
"typewriter.languageServer.path": "",
"typewriter.languageServer.arguments": []
}
π£ Visual Studio 2026
SDK-style VSIX in src/Typewriter.VisualStudio. Tools β Options β Typewriter:
| Category | Options |
|---|---|
| CLI | CLI path, CLI arguments |
| Language Server | Enabled (default β), path, arguments |
| Generation | Workspace path, project path, target framework, generate all projects, generate on save (default β) |
Diagnostics land in the Error List, logs in the Output window, and .tst files get classification plus Ctrl+Space completions.
π§ JetBrains Rider
Frontend plugin in rider/ (plugin README). It is an IntelliJ Platform plugin written in Kotlin for Rider UI concerns: .tst file recognition, syntax highlighting, Tools β Typewriter actions, project settings, and save-time generation/validation through the CLI.
The plugin also registers the Typewriter language server through the IntelliJ Platform LSP API, giving Rider the same IntelliSense as VS Code and Visual Studio: template member completion and hover with documentation, real Roslyn IntelliSense inside ${ ... } C# helper blocks (including project-aware type completions), live diagnostics, and go-to-definition. Toggle it under Settings β Typewriter β Enable language server IntelliSense (enabled by default).
This is not a ReSharper backend plugin. Deep C# semantic loading remains in the shared Typewriter CLI/Roslyn engine, so the Rider plugin can stay thin unless future Rider-native inspections or refactorings require a C# backend.
π Language Server (any LSP client)
Typewriter.LanguageServer speaks stdio JSON-RPC and provides live template diagnostics, completion, hover, go-to-definition, and semantic tokens. Initialization options: workspacePath, projectPath, framework, allProjects.
π§ͺ Samples
Feature samples and issue regressions in samples/ ship with checked-in TypeScript snapshots. The snapshot suite compares generated output exactly, including line endings.
| Sample | Demonstrates |
|---|---|
SimpleApi | π Basic model generation |
NullableModels | π― Nullable reference types β T | null |
RecordsAndEnums | π C# records and enum generation |
MultiProjectSolution | ποΈ Multi-project workspaces and project references |
WebApiServices | π Controllers β typed API clients, constants generation |
SignalRHubs | π‘ SignalR hub β typed client interfaces |
Issue regression samples:
| Sample | Regression covered |
|---|---|
issue66 | Conflicting versions of the same referenced package |
issue67 | Projects that use source generators |
issue68 | Implicit-using isolation across referenced projects |
issue69 | Old-style non-SDK .NET Framework project loading |
issue69v2 | Legacy imported project shapes and captured design-time build failures |
issue74 | Types from transitively referenced projects |
issue75 | Localized resources and satellite assemblies |
issue81 | Cyclic metadata graphs without stack overflow |
issue90 | Closed generic arguments in properties and collections |
issue96 | Cross-file record inheritance |
π Migrating from the Original Typewriter
Coming from the original VS extension? Your .tst templates should run unchanged β the differences are operational:
| Original behavior | Replacement |
|---|---|
| π± VS-only, DTE/COM based | Cross-platform CLI + LSP + editor adapters |
| βAuto-render when C# files changeβ VS option | typewriter watch (or editor generate-on-save) |
| βRender template on saveβ VS option | VS: Generate on save Β· VS Code: typewriter.generateOnSave |
| βAdd generated files to VS projectβ | Not needed β SDK-style projects glob files automatically |
settings.IncludeReferencedProjects() etc. | --project, --all-projects, workspace selection |
| UTF-8 with BOM by default | UTF-8 without BOM β set "encoding": "utf-8-bom" to match old output |
Strict-null toggle via DisableStrictNullGeneration() | β
Works; set "strictNull": false in typewriter.json or call it in the template |
π Detailed parity tracking lives in compatibility.md (template-runtime gaps) and implemented.md (full implementation record).
ποΈ Architecture
src/
βββ Typewriter.Abstractions/ # π Contracts: configuration, diagnostics, metadata, results
βββ Typewriter.Engine/ # βοΈ Template parsing, rendering, compiled helpers, file writing
βββ Typewriter.Roslyn/ # π¬ C# metadata extraction (symbols, nullability, attributes, docs)
βββ Typewriter.Buildalyzer/ # ποΈ Solution/project loading without Visual Studio
βββ Typewriter.Cli/ # π οΈ System.CommandLine front end, watch mode, JSON output
βββ Typewriter.LanguageServer/ # π LSP server (diagnostics, completion, hover, tokens)
βββ Typewriter.VisualStudio/ # π£ VS 2026 VSIX adapter (bridges to the CLI)
vscode/ # π VS Code extension (bridges to CLI + LSP)
rider/ # π§ JetBrains Rider frontend plugin (bridges to CLI)
samples/ # π§ͺ Sample projects with snapshot-tested output
tests/ # β
Unit, CLI integration, and snapshot tests
Design principles:
- π§± Editor-independent core β the engine never references an IDE; editors shell out to the CLI or talk LSP
- π‘οΈ Safe writes β output is planned first: paths outside the workspace and overwrites of non-generated files are refused
- π§ Duplicate-output visibility β duplicate planned output paths are reported as
TW0008warnings before the last render wins - πΈ Snapshot-driven compatibility β real recipes from
NetCoreTypewriterRecipesare the source of truth - β»οΈ Collectible helper assemblies β compiled template helpers load into unloadable
AssemblyLoadContexts so watch mode stays lean
π¨ Building from Source
Prerequisites:
- πͺ .NET SDK 10.0.301 or a compatible latest .NET 10 SDK (pinned in
global.json) - π© Latest stable Node.js managed with Volta (VS Code extension)
- π¦ Visual Studio 2026 (only for working on the VSIX)
- β Eclipse Temurin JDK 21 (
EclipseAdoptium.Temurin.21.JDK; the Rider plugin uses the checked-in Gradle wrapper)
Example Windows setup:
winget install Microsoft.DotNet.SDK.10
winget install EclipseAdoptium.Temurin.21.JDK
winget install Volta.Volta
volta install node
# Clone with submodules (Buildalyzer fork)
git clone --recurse-submodules https://github.com/AdaskoTheBeAsT/Typewriter.git
cd Typewriter
# Restore, build, test
dotnet restore AdaskoTheBeAsT.Typewriter.slnx
dotnet build AdaskoTheBeAsT.Typewriter.slnx --configuration Release --no-restore -m:1
dotnet test AdaskoTheBeAsT.Typewriter.slnx --configuration Release --no-build -m:1
# VS Code extension
npm ci --prefix vscode
npm --prefix vscode run lint
npm --prefix vscode run bundle
pwsh ./Build-VSCodeExtension.ps1 -OutputDirectory artifacts/packages
# JetBrains Rider plugin
./rider/gradlew -p rider verifyPluginProjectConfiguration
./rider/gradlew -p rider verifyPlugin
./rider/gradlew -p rider buildPlugin
# Pack the dotnet tools
dotnet pack src/Typewriter.Cli/Typewriter.Cli.csproj --configuration Release --output artifacts/packages
dotnet pack src/Typewriter.LanguageServer/Typewriter.LanguageServer.csproj --configuration Release --output artifacts/packages
# Build the VSIX
dotnet build src/Typewriter.VisualStudio/Typewriter.VisualStudio.csproj --configuration Release -m:1
β οΈ Keep
-m:1β parallel MSBuild is intentionally disabled for this solution.Packaging commands only create local artifacts. They do not publish to NuGet, Visual Studio Marketplace, or JetBrains Marketplace.
πΊ Project Status and Roadmap
| Milestone | Status |
|---|---|
| Core engine, Roslyn metadata, first template execution | β |
CLI (generate, validate, watch, list-templates, JSON output) | β |
| VS Code extension + Language Server | β |
| Visual Studio 2026 VSIX (AMD64 + ARM64) | β |
| JetBrains Rider frontend plugin | β |
| Old-template compatibility hardening | π§ ongoing |
| NuGet publishing for CLI and language-server tools | β |
| GitHub release assets for VS Code, Visual Studio, and Rider | β |
| Visual Studio / VS Code / JetBrains Marketplace publishing | π planned |
- π
implemented.mdβ everything that is done, in detail - π
to_implement.mdβ roadmap and backlog - π
compatibility.mdβ original-Typewriter parity status
Changelog
4.9.0
- additional release to fix publish error
4.8.0
- Added opt-in date-library profiles for native
Date, Temporal, Moment, Luxon, date-fns, Day.js, and js-joda throughoutput.dateLibraryandSettings.UseDateLibrary(...). - Added semantic date mapping for System and NodaTime values, including distinct elapsed
Durationand calendarPeriodkinds plus year-month and month-day values. - Added
AdaskoTheBeAsT.Typewriter.Annotationswith member-level semantic overrides for ambiguous values such asDateTime. - Added
GenerateFrontendTypeAttribute,AsStringAttribute,LabelForEnumAttribute, andCustomNameAttributeto the Annotations package so recipes can reference a canonical NuGet package instead of copy-pasting attribute definitions. - Added
guidInitializer/UseGuidInitializer(...)anddecimalInitializer/UseDecimalInitializer(...), with automatic defaults for string andUint8ArrayUUIDs, numeric decimals, and Decimal.js. - Preserved the 4.7.0 generated output and low-level date settings under the default
legacyprofile. - Documented the decision in
docs/adr/0001-semantic-date-library-profiles.md.
4.7.0
- Added a much richer shared Language Server experience for
.tsttemplates: documented completions and hover, semantic highlighting, go-to-definition, project-aware template completions, and Roslyn-backed IntelliSense inside${ ... }C# helper blocks. - Added VS Code embedded-language forwarding through virtual C# and TypeScript documents, so the installed C# extension and TypeScript service can provide completion, hover, and definitions inside template helper/output regions. This is controlled by
typewriter.embeddedLanguages.forwarding. - Added Rider LSP integration through the IntelliJ Platform LSP API, allowing Rider to use the shared Typewriter language server while retaining CLI-based generation and validation fallbacks.
- Fixed
Settings.IncludeProject(name)so templates can explicitly merge a named workspace project, plus its referenced projects, into the current template code model. Missing projects now raiseTW0009, andsamples/issue98covers the unreferenced-project scenario. - Improved watch mode and editor generate-on-save performance with changed-input tracking.
typewriter watch, IDE integrations, and the language server can now pass changed paths through--changed/changedInputs, and the defaultgeneration.incremental: "auto"mode re-renders only affected outputs when only C# source files changed. - Added safe full-generation fallbacks for incremental runs when change provenance is unknown, the changed input is a template/project/config file, a file was deleted or renamed, or incremental generation is disabled with
generation.incremental: "off". - Improved Roslyn metadata caching for incremental generation with dirty-path invalidation, source-only rebuilds, syntax-tree and type-reference reuse, reverse dependency indexing, and optional metadata cache metrics in performance traces.
- Added configuration/schema support for the new
generation.incrementalsetting and CLI parsing coverage for repeated--changedarguments. - Added regression coverage for embedded-language services, LSP embedded document/range requests,
IncludeProject, incremental rendering, dirty-path metadata refresh, and editor changed-path plumbing. - Added release and maintenance documentation for compatibility status, implemented work, packaging, Rider installation, and generation performance planning/progress.
4.6.1
- Fixed cross-file record inheritance so
Record.BaseRecordresolves when a derived record and its base record are declared in separate source files. - Fixed Visual Studio generate-on-save to skip saved inputs only when neither the project nor workspace contains
.tsttemplates, preserving templates stored outside the saved file's project, and to tolerate documents that do not expose project metadata through DTE. - Fixed project-load diagnostics for legacy non-SDK
.csprojfiles with unresolved<Import>elements: Buildalyzer may report success with zero source files while capturing an MSBuild import error, which is now surfaced as aTW0003diagnostic instead of being silently ignored. - Added a JSON Schema (
typewriter.schema.json) fortypewriter.jsonconfiguration files;typewriter initnow emits a$schemareference for editor validation and autocomplete. - Added
--diffCLI option to include unified diffs for changed files in both text and JSON output, including line-ending-only and final-newline-only changes. - Added support for passing a template directory to
--template. - Added exact snapshot coverage and individual documentation for every issue regression under
samples/.
4.6.0
- Added configurable date-time, date-only, and time-only TypeScript mappings and default initializers with
output.dateType,output.dateOnlyType,output.timeOnlyType, their initializer settings, and matching templateSettingsmethods. - Added NodaTime date/time mapping support for
Instant,LocalDate,LocalTime,LocalDateTime,OffsetDate,OffsetTime,OffsetDateTime, andZonedDateTime. - Added configurable C#
GuidTypeScript mapping withoutput.guidTypeandsettings.UseGuidType(...), defaulting tostringand allowing aliases such asuuidorUUID. - Documented DateOnly, TimeOnly, TimeSpan, NodaTime, Duration, Period, and runtime conversion guidance, including the need for date-interceptors when JSON strings should become date library objects.
4.5.4
- Restored detailed project-load diagnostics for Buildalyzer/MSBuild failures, including file, line, column, and underlying MSBuild error text in
TW0003JSON diagnostics. - Restored full JSON result output for the Visual Studio persistent language-server generation path, so VS Output includes detailed diagnostics instead of only a diagnostic count.
- Added regression coverage for Buildalyzer project-load diagnostics through the loader, CLI JSON output, and language-server generation service.
4.5.3
- Fixed primitive collection imports so
List<int>andIList<int>no longer generate invalid imports such asimport { number } from "./number";. - Changed
DateTime,DateTimeOffset, andDateOnlyTypeScript mapping fromstringtoDateby default, withoutput.dateTypeorsettings.UseDateType(...)available for Moment, Luxon, js-joda, Day.js, date-fns, or other compatible date types. - Added configurable C#
decimalmapping, defaulting tonumberwithoutput.decimalTypeorsettings.UseDecimalType(...)available fordecimal.js. - Fixed generic
$typeinitializers by exposing generic type parameters in generated full names, for exampleSample.Box<T>. - Fixed dictionary key mapping so
IDictionary<int, string>emitsRecord<number, string>instead ofRecord<string, string>.
4.5.2
- Fixed closed generic type mapping so custom generic types keep their arguments, for example
Box<int>now emitsBox<number>,List<Box<int>>emitsBox<number>[], andDictionary<string, Box<int>>emitsRecord<string, Box<number>>. - Fixed IDE generate-on-save for C# source changes when templates are stored outside the saved file's project directory.
- Fixed duplicate assembly identity loading during analyzer/source-generator and template helper resolution, avoiding intermittent "assembly already loaded" failures.
- Updated the Rider plugin build to IntelliJ Platform Gradle Plugin
2.17.0.
4.5.0
- Dramatically improved generation performance, especially for watch mode and IDE generate-on-save workflows. Typewriter now reuses persistent generation processes, scopes template discovery to the owning project, and caches parsed templates, project metadata indexes, glob matchers, generated-file contents, type mappings, and loaded dependencies.
- Reduced unnecessary rendering, formatting, diagnostics, project traversal, and disk I/O. Editor integrations use the faster persistent path when available and retain the CLI as a compatibility fallback.
4.4.0
- Fixed XML doc-comment extraction so inline elements (such as
<see cref="..."/>) are preserved in$DocComment[$Summary]and$DocComment[$Returns]instead of being stripped to their text content, so referenced type names are no longer lost in generated comments. - Added an opt-in
Typewriter.Extensions.Documentationformatter withDocComment.ToJsDoc(),ToJsDocSummary(), andToJsDocReturns()helpers that convert C# XML doc tags to TypeScript JSDoc (for example<see cref="GeometryFieldType"/>becomes{@link GeometryFieldType}, and<c>/<see langword>/<paramref>become backtick code). The metadata stays raw; conversion is applied only when a template calls these helpers.
4.3.0
- Added property and instance-field initializer values to the metadata model, exposed to templates as
$Value(for example$Properties[$Value]and$Fields[$Value]). - Initializer values come from the source declaration: literals render as their text value, non-literal initializers (such as
Guid.NewGuid()) render as the expression text, and members without an initializer render empty. - Added Roslyn extraction and renderer support plus language-server completion documentation for the new
$Valuemember, covered by engine and Roslyn tests.
4.2.0
- Restored the editor right-click commands Render Template and Generate Current Template so a template can be regenerated on demand from the context menu.
- Fixed a stack overflow that occurred while extracting metadata for types with cyclic references.
4.1.1
- Fixed resolution of types that come from transitively referenced projects.
- Fixed remaining metadata-loading issues for older full .NET Framework projects.
- Fixed handling of satellite (localized) assemblies during project loading.
4.1.0
- Added struct metadata and template rendering through
$Structs[...],$Types(Struct)[...], CodeModelStruct, and LSP/editor completions. - Added indexer metadata through
Property.IsIndexerand property-level$Parameters[...], intended for lookup-style API generation rather than JSON DTO payloads. - Added literal dollar escaping with
$$, so templates can emit$type,${...}, and other TypeScript dollar syntax without triggering template member lookup. - Fixed processing of full .NET Framework projects.
- Fixed implicit
usingresolution for referenced projects. - Fixed loading of projects that use source generators.
- Fixed conflicts when two projects reference different versions of the same package.
- Added tests that cover Roslyn extraction, template rendering, CodeModel parity, and language-server completions for structs and indexers.
4.0.0
- First stable release of the ground-up, cross-platform reimplementation: a shared Roslyn-powered engine with a CLI, Language Server, VS Code extension, Visual Studio 2026 VSIX, and JetBrains Rider plugin.
- Documentation and packaging refinements over
4.0.0-beta.1.
4.0.0-beta.1
- Initial public preview of the new toolchain:
typewriter init,generate,validate,watch, andlist-templates, withtypewriter.jsonconfiguration and discovery. - Editor-independent engine running the original
.tstdialect, plus compiled C# helpers, shared#loadsource helpers, and NuGet#rreferences. - Safe output planning (
TW0005/TW0006/TW0008), template logging (TW0007), and deterministic CLI exit codes with text/JSON output. - Language Server features: live diagnostics, completion, hover, go-to-definition, and semantic tokens.
π€ Contributing
- π΄ Fork and create a feature branch:
git checkout -b feature/amazing-feature - π§Ή Match the existing style β analyzers are wired through
Directory.Build.props,.editorconfig, and StyleCop - β
Add tests (unit, CLI integration, or snapshot) and make
dotnet test ... -m:1pass - πΈ For template-compatibility work, prefer real recipe fixtures over synthetic cases
- π Open a pull request with a clear description
π Issues and Support
- π Bugs and feature requests: GitHub Issues
- β Questions: Stack Overflow
typewritertag - π³ Template recipes: NetCoreTypewriterRecipes
π Acknowledgments
- Fredrik Hagnelius β creator of the original Typewriter
- AdaskoTheBeAsT/Typewriter β the maintained VS extension fork this project grew from
- Buildalyzer β design-time project analysis (vendored fork in
Buildalyzer/) - The .NET and TypeScript communities β€οΈ
Made with β€οΈ by the community, for the community