Typewriter

July 23, 2026 Β· View on GitHub

Typewriter

Generate TypeScript from C#, Everywhere

CI .NET C# Platforms PRs Welcome

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


✨ 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 .tst templates β€” filters, lambdas, shared local/remote helpers, and compiled C# helper methods with #r NuGet references
  • 🧩 Legacy compatible β€” runs the original Typewriter template dialect, validated against real-world recipes
  • πŸ“… Configurable temporal types β€” map DateTime, DateOnly, TimeOnly, NodaTime types, Guid, and decimal to 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 .tst files

πŸš€ 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.

AreaClassic Typewriter forkThis version
Runtime modelVisual Studio extension centered around VS events, DTE/COM, and VS project integrationEditor-independent engine plus thin adapters for CLI, LSP, VS Code, Visual Studio, and Rider
AutomationBest inside Visual Studio, difficult to run consistently in headless buildstypewriter generate, validate, watch, and list-templates work locally and in CI
ConfigurationMostly per-template Settings and Visual Studio optionsRepository-local typewriter.json with discovery, formatting, encoding, nullability, naming, dry-run, and warning policy
CI behaviorNo first-class JSON contract or deterministic command exit modelJSON/text output, dry-run, deterministic exit codes, and --fail-on-warning
Editor experienceVisual Studio editor supportLanguage Server diagnostics, completion, hover, go-to-definition, and semantic tokens, reused by VS Code and any LSP client
IDE coverageVisual Studio 2022 focusedCLI, VS Code, Visual Studio 2026, JetBrains Rider, and any LSP-capable editor
Project loadingVisual Studio-oriented solution/project contextBuildalyzer and Roslyn loading for solutions, projects, folders, target frameworks, references, and multi-project workspaces
File safetyGenerated file behavior depends on the VS extension workflowPlanned writes block paths outside the workspace, refuse non-Typewriter overwrites, skip unchanged files, and warn on duplicate outputs
Template runtimeClassic helpers and settingsClassic helpers plus #load, NuGet #r, settings.Log diagnostics, Template(Settings, File), OnRenderComplete, parent/current helper parameters, and richer stack traces
Type mappingMostly template-authored conventionsConfigurable date-time, date-only, time-only, NodaTime, Guid, and decimal mappings with matching default initializers
Metadata depthClassic type/member surfaceStructs, indexers, member initializer $Value, preserved XML doc inline elements, and JSDoc formatting helpers
PerformanceEvent-driven generation per IDE workflowPersistent generation, scoped discovery, metadata indexes, cached templates, glob matchers, type mappings, and loaded dependencies
PackagingVSIX releasesNuGet 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 watch regenerates when configured input file extensions change.
  • Safer generated output: TW0005 blocks escaping the workspace, TW0006 protects hand-written files, and TW0008 exposes duplicate output paths.
  • Better large-template ergonomics: Template(Settings settings, File file) lets templates count and pre-filter types before rendering, while OnRenderComplete(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.Documentation can 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, and decimal mappings, 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 NetCoreTypewriterRecipes are 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 $BaseClass or $Value means 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 actual Typewriter.CodeModel API. The whole code model is XML-documented, so hovering .BaseClass on a Class explains "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/templateRange LSP 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 .tst positions. Controlled by typewriter.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

ComponentPathWhat it does
πŸ› οΈ Typewriter CLIsrc/Typewriter.Cliinit, generate, validate, watch, list-templates β€” scriptable and CI-ready
βš™οΈ Enginesrc/Typewriter.EngineTemplate parsing, rendering, compiled C# helpers, output planning and writing
πŸ”¬ Roslyn metadatasrc/Typewriter.RoslynExtracts classes, records, interfaces, enums, delegates, members, attributes, doc comments
πŸ—οΈ Buildalyzer bridgesrc/Typewriter.BuildalyzerLoads solutions/projects and resolves references without Visual Studio
🌐 Language Serversrc/Typewriter.LanguageServerLSP: live diagnostics, completion, hover, go-to-definition, semantic tokens
πŸ’œ VS Code extensionvscode/.tst language support, commands, Problems integration, LSP client
🟣 Visual Studio 2026 extensionsrc/Typewriter.VisualStudioSDK-style VSIX for AMD64 and ARM64: generate on save, Error List diagnostics, Output window logging
🧠 JetBrains Rider pluginrider/IntelliJ frontend plugin: .tst highlighting, Tools menu actions, settings, save-time CLI generation
πŸ“ Abstractionssrc/Typewriter.AbstractionsShared 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.

IDEDownloadInstall
VS Codetypewriter-vscode-<version>.vsixRun code --install-extension typewriter-vscode-<version>.vsix, or use Extensions β†’ ... β†’ Install from VSIX...
Visual Studio 2026Typewriter.VisualStudio-<version>.vsixDouble-click the VSIX, or open it with the Visual Studio VSIX installer, then restart Visual Studio
JetBrains RiderTypewriter-Rider-<version>.zipUse 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 watch once and forget about it β€” every C# or template save regenerates the affected TypeScript.


πŸ›  CLI Reference

typewriter [command] [options]

Commands

CommandDescription
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

OptionDescription
--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-projectsGenerate for every project in a multi-project workspace
--output text|jsonOutput format (use json in CI and tooling)
--dry-runRender everything, write nothing
--diffInclude unified diffs, including line-ending and final-newline changes
--fail-on-warningNon-zero exit code when warnings are emitted

Exit codes

CodeMeaning
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

CodeMeaning
TW0001Unknown template member
TW0002Template parse error
TW0003Project load failed
TW0004C# compilation diagnostic surfaced from Roslyn
TW0005Output path escapes the workspace (blocked for safety)
TW0006Output would overwrite a file not generated by Typewriter (blocked)
TW0007Template log message (settings.Log) surfaced as a diagnostic
TW0008Duplicate generated output path warning; generation continues and the last render wins unless --fail-on-warning is enabled
TW0009Project 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

KeyDefaultDescription
templates["**/*.tst"]Glob patterns used to discover templates
excludebin, obj, node_modules globsGlob patterns excluded from discovery
inputExtensions.cs, .csproj, .json, .props, .sln, .slnx, .targets, .tstFile extensions that trigger typewriter watch and editor generate-on-save. Dot is optional
defaultTargetFrameworknullTFM used when a project multi-targets (e.g. net10.0)

output settings

KeyDefaultDescription
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
writeOnlyWhenChangedtrueSkip disk writes when content is unchanged (keeps file watchers and HMR calm)
dryRunfalseRender without writing files
fileNameConvention"preserve"Output file naming: preserve, kebab, pascal, camel, snake
strictNulltrueRender 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)
indentSize4Target indent width used when indentStyle is space
insertFinalNewlinefalseEnsure generated files end with a single trailing newline
trimTrailingWhitespacefalseStrip 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 valueTemplate API equivalentProfile
"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# typeSemantic kindNative DateTemporalMomentLuxondate-fnsDay.jsjs-joda
DateTimeplain date-timeDateTemporal.PlainDateTimemoment.MomentDateTimeDateDayjsLocalDateTime
DateTimeOffsetinstantDateTemporal.Instantmoment.MomentDateTimeDateDayjsInstant
DateOnlyplain dateDateTemporal.PlainDatemoment.MomentDateTimeDateDayjsLocalDate
TimeOnlyplain timestringTemporal.PlainTimestringDateTimestringstringLocalTime
TimeSpanelapsed durationstringTemporal.Durationmoment.DurationDurationDurationReturnType<typeof dayjs.duration>Duration
NodaTime.InstantinstantDateTemporal.Instantmoment.MomentDateTimeDateDayjsInstant
NodaTime.LocalDateplain dateDateTemporal.PlainDatemoment.MomentDateTimeDateDayjsLocalDate
NodaTime.OffsetDateplain dateDateTemporal.PlainDatemoment.MomentDateTimeDateDayjsLocalDate
NodaTime.LocalTimeplain timestringTemporal.PlainTimestringDateTimestringstringLocalTime
NodaTime.OffsetTimeplain timestringTemporal.PlainTimestringDateTimestringstringLocalTime
NodaTime.LocalDateTimeplain date-timeDateTemporal.PlainDateTimemoment.MomentDateTimeDateDayjsLocalDateTime
NodaTime.OffsetDateTimeinstantDateTemporal.Instantmoment.MomentDateTimeDateDayjsInstant
NodaTime.ZonedDateTimezoned date-timestringTemporal.ZonedDateTimestringDateTimestringstringZonedDateTime
NodaTime.Durationelapsed durationstringTemporal.Durationmoment.DurationDurationDurationReturnType<typeof dayjs.duration>Duration
NodaTime.Periodcalendar periodstringTemporal.Durationmoment.DurationDurationDurationReturnType<typeof dayjs.duration>Period
NodaTime.YearMonthplain year-monthstringTemporal.PlainYearMonthstringDateTimestringstringYearMonth
NodaTime.AnnualDateplain month-daystringTemporal.PlainMonthDaystringDateTimestringstringMonthDay

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:

AttributeApplies toPurpose
GenerateFrontendTypeAttributeClass, interface, enum, methodMarks a type or method for inclusion in frontend generation; optional DllName routes the type to a specific output assembly
FrontendRuntimeTypeAttributeProperty, field, parameterOverrides the semantic runtime type for a single member
FrontendNoTransformAttributeProperty, field, parameterEmits the value as a plain passthrough, skipping runtime transformation
AsStringAttributeEnumEmits enum values as strings instead of numbers
LabelForEnumAttributeField (enum member)Attaches a human-readable label to an enum value
CustomNameAttributeMethodOverrides 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 runtimeDate-time configDate-only configTime-only configType importsConverter
Native DatedateType: "Date", dateInitializer: "new Date()"dateOnlyType: "Date", dateOnlyInitializer: "new Date()"timeOnlyType: "string", timeOnlyInitializer: "\"00:00:00\""none@adaskothebeast/hierarchical-convert-to-date
date-fnssame as native Datesame as native DateISO stringnone@adaskothebeast/hierarchical-convert-to-date-fns
Day.jsDayjs, dayjs()Dayjs, dayjs()ISO stringimport type { Dayjs } from 'dayjs';@adaskothebeast/hierarchical-convert-to-dayjs
Moment.jsMoment, moment()Moment, moment()ISO stringimport type { Moment } from 'moment';@adaskothebeast/hierarchical-convert-to-moment
LuxonDateTime, DateTime.now()DateTime, DateTime.now().startOf('day')ISO stringimport type { DateTime } from 'luxon';@adaskothebeast/hierarchical-convert-to-luxon
js-jodaZonedDateTime, 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
TemporalTemporal.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 typeRecommended TypeScript shapeNotes
DateTime, DateTimeOffset, NodaTime.Instant, NodaTime.OffsetDateTime, NodaTime.ZonedDateTimeDate, Temporal.Instant, Luxon DateTime, Day.js Dayjs, or js-joda Instant/ZonedDateTimeUse 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.OffsetDateISO string, Temporal.PlainDate, or js-joda LocalDateAvoid native Date for pure dates if timezone shifts matter.
TimeOnly, NodaTime.LocalTime, NodaTime.OffsetTimeISO string, Temporal.PlainTime, or js-joda LocalTimeJavaScript has no native time-only type.
NodaTime.LocalDateTimeTemporal.PlainDateTime, Luxon DateTime, js-joda LocalDateTime, or ISO stringUse output.dateType for this local date-time type.
TimeSpan, NodaTime.DurationISO duration string, Temporal.Duration, js-joda Duration, or a numeric convention such as millisecondsPick one wire format and document it. Duration means elapsed time.
NodaTime.PeriodISO period string, Temporal.Duration, js-joda Period, or date-fns DurationPeriod 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

KeyDefaultDescription
failOnWarningfalseTreat warnings as failures (great for CI)

πŸ” Discovery and precedence

  1. Configuration files are looked up by name, in order: typewriter.json, typewriter.config.json, .typewriterrc.json
  2. Files in the workspace folder load first, then files in the project folder override them
  3. Environment variables override file values
  4. CLI flags (--framework, --dry-run, --fail-on-warning) win over everything

🌱 Environment variables

VariableOverrides
TYPEWRITER_INPUT_EXTENSIONSinputExtensions (comma/semicolon/space list)
TYPEWRITER_DEFAULT_TARGET_FRAMEWORKdefaultTargetFramework
TYPEWRITER_OUTPUT_NEWLINEoutput.newline
TYPEWRITER_OUTPUT_DATE_LIBRARYoutput.dateLibrary
TYPEWRITER_OUTPUT_DATE_TYPEoutput.dateType
TYPEWRITER_OUTPUT_DATE_INITIALIZERoutput.dateInitializer
TYPEWRITER_OUTPUT_DATE_ONLY_TYPEoutput.dateOnlyType
TYPEWRITER_OUTPUT_DATE_ONLY_INITIALIZERoutput.dateOnlyInitializer
TYPEWRITER_OUTPUT_TIME_ONLY_TYPEoutput.timeOnlyType
TYPEWRITER_OUTPUT_TIME_ONLY_INITIALIZERoutput.timeOnlyInitializer
TYPEWRITER_OUTPUT_GUID_TYPEoutput.guidType
TYPEWRITER_OUTPUT_GUID_INITIALIZERoutput.guidInitializer
TYPEWRITER_OUTPUT_DECIMAL_TYPEoutput.decimalType
TYPEWRITER_OUTPUT_DECIMAL_INITIALIZERoutput.decimalInitializer
TYPEWRITER_FAIL_ON_WARNINGdiagnostics.failOnWarning (true/1/yes)

πŸ“ Template Authoring

Templates are .tst files using the original Typewriter dialect β€” existing templates keep working.

Core syntax

ConstructMeaning
$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, $TypeScalar 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.Json and 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:

SettingStatusNotes
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:

EditorArtifactInstall
Visual Studio 2026Typewriter.VisualStudio-<version>.vsixDouble-click the VSIX, or run it with the Visual Studio VSIX installer, then restart Visual Studio
VS Codetypewriter-vscode-<version>.vsixRun code --install-extension typewriter-vscode-<version>.vsix, or use Extensions β†’ ... β†’ Install from VSIX...
JetBrains RiderTypewriter-Rider-<version>.zipUse 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:

CategoryOptions
CLICLI path, CLI arguments
Language ServerEnabled (default βœ”), path, arguments
GenerationWorkspace 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.

SampleDemonstrates
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:

SampleRegression covered
issue66Conflicting versions of the same referenced package
issue67Projects that use source generators
issue68Implicit-using isolation across referenced projects
issue69Old-style non-SDK .NET Framework project loading
issue69v2Legacy imported project shapes and captured design-time build failures
issue74Types from transitively referenced projects
issue75Localized resources and satellite assemblies
issue81Cyclic metadata graphs without stack overflow
issue90Closed generic arguments in properties and collections
issue96Cross-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 behaviorReplacement
πŸ–± VS-only, DTE/COM basedCross-platform CLI + LSP + editor adapters
β€œAuto-render when C# files change” VS optiontypewriter watch (or editor generate-on-save)
β€œRender template on save” VS optionVS: 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 defaultUTF-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 TW0008 warnings before the last render wins
  • πŸ“Έ Snapshot-driven compatibility β€” real recipes from NetCoreTypewriterRecipes are 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

MilestoneStatus
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

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 through output.dateLibrary and Settings.UseDateLibrary(...).
  • Added semantic date mapping for System and NodaTime values, including distinct elapsed Duration and calendar Period kinds plus year-month and month-day values.
  • Added AdaskoTheBeAsT.Typewriter.Annotations with member-level semantic overrides for ambiguous values such as DateTime.
  • Added GenerateFrontendTypeAttribute, AsStringAttribute, LabelForEnumAttribute, and CustomNameAttribute to the Annotations package so recipes can reference a canonical NuGet package instead of copy-pasting attribute definitions.
  • Added guidInitializer / UseGuidInitializer(...) and decimalInitializer / UseDecimalInitializer(...), with automatic defaults for string and Uint8Array UUIDs, numeric decimals, and Decimal.js.
  • Preserved the 4.7.0 generated output and low-level date settings under the default legacy profile.
  • Documented the decision in docs/adr/0001-semantic-date-library-profiles.md.

4.7.0

  • Added a much richer shared Language Server experience for .tst templates: 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 raise TW0009, and samples/issue98 covers 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 default generation.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.incremental setting and CLI parsing coverage for repeated --changed arguments.
  • 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.BaseRecord resolves 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 .tst templates, 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 .csproj files with unresolved <Import> elements: Buildalyzer may report success with zero source files while capturing an MSBuild import error, which is now surfaced as a TW0003 diagnostic instead of being silently ignored.
  • Added a JSON Schema (typewriter.schema.json) for typewriter.json configuration files; typewriter init now emits a $schema reference for editor validation and autocomplete.
  • Added --diff CLI 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 template Settings methods.
  • Added NodaTime date/time mapping support for Instant, LocalDate, LocalTime, LocalDateTime, OffsetDate, OffsetTime, OffsetDateTime, and ZonedDateTime.
  • Added configurable C# Guid TypeScript mapping with output.guidType and settings.UseGuidType(...), defaulting to string and allowing aliases such as uuid or UUID.
  • 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 TW0003 JSON 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> and IList<int> no longer generate invalid imports such as import { number } from "./number";.
  • Changed DateTime, DateTimeOffset, and DateOnly TypeScript mapping from string to Date by default, with output.dateType or settings.UseDateType(...) available for Moment, Luxon, js-joda, Day.js, date-fns, or other compatible date types.
  • Added configurable C# decimal mapping, defaulting to number with output.decimalType or settings.UseDecimalType(...) available for decimal.js.
  • Fixed generic $type initializers by exposing generic type parameters in generated full names, for example Sample.Box<T>.
  • Fixed dictionary key mapping so IDictionary<int, string> emits Record<number, string> instead of Record<string, string>.

4.5.2

  • Fixed closed generic type mapping so custom generic types keep their arguments, for example Box<int> now emits Box<number>, List<Box<int>> emits Box<number>[], and Dictionary<string, Box<int>> emits Record<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.Documentation formatter with DocComment.ToJsDoc(), ToJsDocSummary(), and ToJsDocReturns() 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 $Value member, 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)[...], CodeModel Struct, and LSP/editor completions.
  • Added indexer metadata through Property.IsIndexer and 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 using resolution 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, and list-templates, with typewriter.json configuration and discovery.
  • Editor-independent engine running the original .tst dialect, plus compiled C# helpers, shared #load source helpers, and NuGet #r references.
  • 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

  1. 🍴 Fork and create a feature branch: git checkout -b feature/amazing-feature
  2. 🧹 Match the existing style β€” analyzers are wired through Directory.Build.props, .editorconfig, and StyleCop
  3. βœ… Add tests (unit, CLI integration, or snapshot) and make dotnet test ... -m:1 pass
  4. πŸ“Έ For template-compatibility work, prefer real recipe fixtures over synthetic cases
  5. πŸš€ Open a pull request with a clear description

πŸ› Issues and Support


πŸ™ Acknowledgments


Made with ❀️ by the community, for the community

⬆ Back to top