Why C# goes well with TypeScript

TypeScript kicks ass. It hits a pragmatic sweet spot between strong typing and rapid development that makes it a joy to use, which is why it's my go-to language for many tasks. However, no language is perfect, and there are cases where TypeScript probably isn't the best tool for the job:

For cases like these, it's helpful for TypeScript developers to have another language in their toolbelt. C#, Go, and Java are all great options1! They're all significantly faster than TypeScript (and perform similarly to each other), and each language has its own unique strengths. C# is a particularly well-suited companion to TypeScript, though, and I'll explain why.

C# and TypeScript: more similar than different

C# and TypeScript go well together because they feel like similar languages. They were both designed by Anders Hejlsberg2, and in many ways, TypeScript feels like the result of adding parts of C# to JavaScript. They share similar features and syntax, so it's easy to use them together within the same project or team without much extra thought. Most importantly, since C# and TypeScript are similar, developers can reason about and write code in a similar way.

In contrast, Go feels like a very different language: no classes or inheritance, no exceptions, package-level encapsulation (instead of class-level), and more differences in syntax. That's not inherently a bad thing! But it does require developers to think about and design code quite differently, and as a result, that makes it more difficult to use Go and TypeScript together. Java, meanwhile, is similar to C#, but it still lacks many of the nice qualities that C# and TypeScript share in common.

Similarities between C# and TypeScript

You probably already know that C# and TypeScript share many of the basics (C-inspired syntax, classes, interfaces, generics, etc.), so I'll jump straight into the more interesting aspects that the languages share:

After that, I'll circle back to the basics and then mention a few more perks of C#.

async / await

First off, C# and JavaScript both handle asynchronous code using async / await. In JavaScript, an asynchronous operation is represented as a Promise, which the application can await in order to run code after the asynchronous operation completes. C#'s equivalent to a Promise is a Task, which is conceptually identical and also has equivalent methods. To illustrate that, here's an example of using async / await to implement the same function in both languages:

async function fetchAndWriteToFile(url: string, filePath: string): Promise<string> {
  // fetch() returns a Promise
  const response = await fetch(url);
  const text = await response.text();
  // By the way, we're using Deno (https://deno.land)
  await Deno.writeTextFile(filePath, text);
  return text;
}
Figure 1.1: TypeScript example of async / await
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;

async Task<string> FetchAndWriteToFile(string url, string filePath) {
  // HttpClient.GetAsync() returns a Task
  var response = await new HttpClient().GetAsync(url);
  var text = await response.Content.ReadAsStringAsync();
  await File.WriteAllTextAsync(filePath, text);
  return text;
}
Figure 1.2: C# example of async / await

And here's a mapping of JavaScript Promise APIs to the equivalent C# Task APIs:

JavaScript API Equivalent C# API
Promise.prototype.then() Task.ContinueWith()
new Promise() new TaskCompletionSource()
Promise.resolve() Task.FromResult()
Promise.reject() Task.FromException()
Promise.all() Task.WhenAll()
Promise.race() Task.WhenAny()

Lambda expressions + functional array methods

C# and JavaScript also share the same familiar => syntax for lambda expressions (a.k.a. arrow functions). Here's another comparison of TypeScript to equivalent C#:

const months = ['January', 'February', 'March', 'April'];
const shortMonthNames = months.filter(month => month.length < 6);
const monthAbbreviations = months.map(month => month.substr(0, 3));
const monthStartingWithF = months.find(month => {
  return month.startsWith('F');
});
Figure 2.1: TypeScript example of lambda expressions
using System.Collections.Generic;
using System.Linq;

var months = new List<string> {"January", "February", "March", "April"};
var shortMonthNames = months.Where(month => month.Length < 6);
var monthAbbreviations = months.Select(month => month.Substring(0, 3));
var monthStartingWithF = months.Find(month => {
  return month.StartsWith("F");
});
Figure 2.2: C# example of lambda expressions

The example above also shows how C#'s System.Linq namespace contains methods equivalent to JavaScript's functional array methods. Here's a mapping of JavaScript array methods to the equivalent C# Linq methods:

JavaScript API Equivalent C# API
Array.prototype.filter() Enumerable.Where()
Array.prototype.map() Enumerable.Select()
Array.prototype.reduce() Enumerable.Aggregate()
Array.prototype.find() List.Find()
Array.prototype.findIndex() List.FindIndex()
Array.prototype.every() Enumerable.All()
Array.prototype.some() Enumerable.Any()
Array.prototype.flatMap() Enumerable.SelectMany()

Operators for handling nullability

C# and TypeScript also share many nifty features for handling null / optional values:

Feature name Syntax Documentation links
Optional properties property? TS / C#
Non-null assertion object!.property TS / C#
Optional chaining object?.property JS / C#
Nullish coalescing object ?? alternativeValue JS / C#

Destructuring

Although C# doesn't support destructuring for arrays or normal classes by default, it does support destructuring Tuples and Records out-of-the-box, and users can also define destructuring for custom types. Here are some examples of destructuring in TypeScript and C#:

const author = { firstName: 'Kurt', lastName: 'Vonnegut' };
// Destructuring an object:
const { firstName, lastName } = author;

const cityAndCountry = ['Indianapolis', 'United States'];
// Destructuring an array:
const [city, country] = cityAndCountry;
Figure 3.1: TypeScript example of destructuring
using System;

var author = new Author("Kurt", "Vonnegut");
// Deconstructing a record:
var (firstName, lastName) = author;

var cityAndCountry = Tuple.Create("Indianapolis", "United States");
// Deconstructing a tuple:
var (city, country) = cityAndCountry;

// Define the Author record used above
record Author(string FirstName, string LastName);
Figure 3.2: C# example of destructuring

Command Line Interface (CLI)

My preferred dev workflow is to use a text editor to write code and then run commands from a terminal to build and run it. For TypeScript, that means using the node or deno command line interfaces (CLIs) in the terminal. C# has a similar CLI called dotnet (named for C#'s .NET runtime). Here are some examples of using the dotnet CLI:

mkdir app && cd app
# Create a new console application
# List of available app templates: https://docs.microsoft.com/dotnet/core/tools/dotnet-new
dotnet new console
# Run the app
dotnet run
# Run tests (don't feel bad if you haven't written those)
dotnet test
# Build the app as a self-contained
# single file application for Linux.
dotnet publish -c Release -r linux-x64

The basics (classes, generics, errors and enums)

That covers some of the more unique similarities between C# and TypeScript, so now I'll loop back to the basics to show how the languages have similar syntax for classes, interfaces, enums, generics, exceptions, and string interpolation. I combined those concepts into the following short example:

import { v4 as uuidv4 } from 'https://deno.land/std/uuid/mod.ts';

enum AccountType {
  Trial,
  Basic,
  Pro
}

interface Account {
  id: string;
  type: AccountType;
  name: string;
}

interface Database<T> {
  insert(item: T): Promise;
  get(id: string): Promise<T>;
}

class AccountManager {

  constructor(database: Database<Account>) {
    this._database = database;
  }

  async createAccount(type: AccountType, name: string) {
    try {
      const account = {
        id: uuidv4(),
        type,
        name;
      };
      await this._database.insert(account);
    } catch (error) {
        console.error(`An unexpected error occurred while creating an account. Name: ${name}, Error: ${error}`);
    }
  }

  private _database: Database<Account>;
}
Figure 4.1: TypeScript class example
using System;
using System.Threading.Tasks;

enum AccountType {
  Trial,
  Basic,
  Pro
}

record Account(string Id, AccountType Type, string Name);

interface IDatabase<T> {
  Task Insert(T item);
  Task<T> Get(string id);
}

class AccountManager {

  public AccountManager(IDatabase<Account> database) {
    _database = database;
  }

  public async void CreateAccount(AccountType type, string name) {
    try {
      var account = new Account(
        Guid.NewGuid().ToString(),
        type,
        name
      );
      await _database.Insert(account)
    } catch (Exception exception) {
      Console.WriteLine($"An unexpected error occurred while creating an account. Name: {name}, Exception: {exception}");
    }
  }

  IDatabase<Account> _database;
}
Figure 4.2: C# class example

Other benefits of C#

Being similar to TypeScript isn't the only cool thing about C#. It has some other unique benefits, too:

Interop with native code

One of C#'s great strengths is that it's easy to dip down into native code if needed. I mentioned at the top of the article that integrating with native C/C++ code isn't one of TypeScript's strong suits. Node.js has an API for native C/C++ plugins called Node-API, but it requires writing additional C++ wrappers around native functions to convert native types to JavaScript objects and vice versa, similar to how the Java Native Interface works. In contrast, C# lets you call native functions simply by placing the library in your app's bin folder and then declaring the APIs as extern functions in C#. You can then use the extern functions as if they were normal C# functions, and the .NET runtime takes care of converting C# data types into native data types and vice versa. For example, if your native library exports the following C function:

int countOccurrencesOfCharacter(char *string, char character) {
    int count = 0;
    for (int i = 0; string[i] != '\0'; i++) {
        if (string[i] == character) {
            count++;
        }
    }
    return count;
}

...then you can call it from C# like this:

using System;
using System.Runtime.InteropServices;

var count = MyLib.countOccurrencesOfCharacter("C# is pretty neat, eh?", 'e');
// Prints "3"
Console.WriteLine(count);

class MyLib {
    // Just place MyLibraryName.so in the app's bin folder
    [DllImport("MyLibraryName")]
    public static extern int countOccurrencesOfCharacter(string str, char character);
}

You can use this approach with any dynamic library (.so, .dll, or .dylib) with C linkage, which means you can easily call into code written in C, C++, Rust, Go, or other languages that compile to machine code. Some other useful things you can do with native interop:

Events

One unique feature of C# that I wish other languages would copy is that events are a first class member of the language. Whereas in TypeScript you might implement an addEventListener() method to let clients listen for events, C# has an event keyword for declaring an event and a simple syntax to notify all of the listeners of an event at once (without needing to iterate through all of the event listeners manually and execute them in a try/catch block like you would in TypeScript). For example, we can make a Connection class that declares a MessageReceived event like this:

class Connection {
    // An Action<string> is a callback that accepts a string parameter.
    public event Action<string> MessageReceived;
}

Code that uses a Connection can attach a handler to its MessageReceived event using the += operator, like this:

var connection = new Connection();
connection.MessageReceived += (message) => {
    Console.WriteLine("Message was received: " + message);
};

And the Connection class can internally trigger its MessageReceived event for all of the event's listeners at once by invoking MessageReceived once, like this:

// Raise the MessageReceived event
MessageReceived?.Invoke(message);

Other perks

Conclusion

Like I mentioned, no language is perfect. There are tradeoffs in language design, so some language features that make a language fast also make it more difficult to use (example: Rust's borrow checker). On the flip side, language features that make a language easy to use may make it more difficult to optimize for performance (example: JavaScript's dynamic nature). Because of this, I believe it's useful to have a "language stack": a group of languages that have different strengths, but are similar and work together well. For example, here's my go-to language stack:

Footnotes

  1. Why didn't I mention Rust or C++ here? They're powerful languages and will give you the best performance, but they also add significant complexity and longer build times, which generally makes development slower and more complicated. So, I didn't mention them as general alternatives to TypeScript, but they may be a reasonable option depending on the use case.
  2. Anders designed the TypeScript layer on top of JavaScript, but he didn't design JavaScript itself. JavaScript was originally designed by Brendan Eich and then later developed by browser vendors under TC-39.