Keeping your C# and TypeScript models in sync

jsinsa logo

I've been working on a new Angular (v4) application that talks to a C# (asp.net core) backend. One of the things I love about Angular is TypeScript, and one of the things I love about TypeScript is the ability to define the models you'll send and receive. I love me some Intellisense when writing my code.

Now, C# and TypeScript are very similar, but also sufficiently different that I couldn't just copy my C# viewmodels across to TypeScript land. So I went searching for some solutions.

I came across a few websites that would do the conversion, but they were all pretty opinionated about the way the TypeScript classes were generated and those opinions didn't seem to match the way my Angular project was setup.

Then I came across a Visual Studio extension called TypeWriter that autogenerates TypeScript models based on C# files.

The defaults didn't match what I needed and it didn't import linked types, but the great thing is that it's heavily customizable and after a bit of trial and error I managed to get exactly what I wanted.

This simple example of C#:

// MyEnum.cs
public enum MyEnum {  
  Value1 = 1,
  Value2 = 2
}

// MyClass1.cs
public class MyClass1 {  
  public int Id {get;set;}
  public string Name {get;set;}
  public MyEnum Type {get;set;}
}

//MyClass2.cs
public class MyClass2 {  
  public bool IsActive {get;set;}
  public MyClass1 OtherClass {get;set;}
}

Becomes this in TypeScript:

// my-enum.ts
export enum MyEnum {  
   value1 = 1,
   value2 = 2
}

//my-class1.ts
import { MyEnum } from './my-enum';

export class MyClass1 {  
  public id: number;
  public name: string;
  public type: MyEnum;
}

//my-class2.ts
import { MyClass1 } from './my-class1';

export class MyClass2 {  
  public isActive: boolean;
  public otherClass: MyClass1;
}

My TypeWriter template file does the following:

  1. Only generates classes that end in "Model"
  2. Generates Enums
  3. Removes the word "Model" from the class name for TypeScript
  4. Converts the filenames to kebab-case (i.e. MyClass to my-class)
  5. Generates imports for referenced non-primitive types
  6. Places the files into a folder called "GeneratedFronted" so they don't polute my C# files.

The template only seems to find files in the same folder as itself, so depending on your project setup, you'll need to add it to multiple folders. It seems that "Add As Link" doesn't work though.

Here is my TypeScriptTemplate.tst file:

${
    using Typewriter.Extensions.Types;
    using System.Text.RegularExpressions;
    using System.Diagnostics;

    string ToKebabCase(string typeName){
        return  Regex.Replace(typeName, "(?<!^)([A-Z][a-z]|(?<=[a-z])[A-Z])","-$1", RegexOptions.Compiled)
                     .Trim().ToLower();
    }

    string CleanupName(string propertyName, bool? removeArray = true){
        if (removeArray.HasValue && removeArray.Value) {
            propertyName = propertyName.Replace("[]","");
        }
        return propertyName.Replace("Model","");
    }

    Template(Settings settings)
    {
        settings.OutputFilenameFactory = (file) => {
            if (file.Classes.Any()){
                var className = file.Classes.First().Name;
                className = CleanupName(className);
                className = ToKebabCase(className);
                return $"GeneratedFrontend\\{className}.ts";
            }
            if (file.Enums.Any()){
                var className = file.Enums.First().Name;
                className = ToKebabCase(className);
                return $"GeneratedFrontend\\{className}.ts";
            }
            return file.Name; 
        };
    }

    string Imports(Class c) => c.Properties
                                .Where(p=>!p.Type.IsPrimitive || p.Type.IsEnum)
                                .Select(p=> $"import {{ {CleanupName(p.Type.Name)} }} from './{ToKebabCase(CleanupName(p.Type.Name))}';")
                                .Aggregate("", (all,import) => $"{all}{import}\r\n")
                                .TrimStart();

    string CustomProperties(Class c) => c.Properties
                                        .Select(p=> $"\tpublic {p.name}: {CleanupName(p.Type.Name, false)};")
                                        .Aggregate("", (all,prop) => $"{all}{prop}\r\n")
                                        .TrimEnd();

    string ClassName(Class c) => c.Name.Replace("Model","");    

}$Classes(*Model)[$Imports
export class $ClassName   {  
$CustomProperties
}]$Enums(*)[export enum $Name { $Values[
    $name = $Value][,]
}]