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:
- like when performance is important (examples: real-time communications or video games)
- or when it's necessary to interface with native code (like C/C++ or Rust)
- or when it's helpful to have a stricter type system (financial systems come to mind)
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:
- async / await
- lambda expressions + functional array methods
- operators for handling nullability (
?
,!
,??
) - destructuring
- command line interface (CLI)
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;
}
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;
}
And here's a mapping of JavaScript Promise APIs to the equivalent C# Task APIs:
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');
});
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");
});
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:
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;
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);
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>;
}
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;
}
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:
- Pass a pointer to a native object as an IntPtr
- Pass a C# method to a native function as a function pointer using GetFunctionPointerForDelegate()
- Marshal a C string to a C# string using Marshal.PtrToStringAnsi()
- Marshal structs and arrays
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
Performance: It's worth underscoring that C# is fast. C#'s ASP.NET web framework consistently ranks toward the top of Techempower's benchmarks, and C#'s .NET CoreCLR runtime has received performance improvements in each major release. One reason for C#'s great performance is that applications can minimize or even eliminate garbage collection by using values types like structs instead of classes. This is one reason why C# is a popular choice for video games, which brings me to my next point:
Games and mixed reality: C# is one of the most popular languages for game development, with game engines like Unity, Godot, or even Unreal. C# is also widely used for mixed reality, since most VR and AR apps are made with Unity.
Some tasks are easier in C# thanks to its streamlined first-party libraries, tooling, and documentation. For example, I found that creating a gRPC client is much simpler in C# than in TypeScript. In C#, you just install the Grpc.Net.Client package, add a reference to your proto file in your csproj file, and then follow the examples in the documentation. In contrast, when using TypeScript with Node.js, I had to embark on an arduous journey to search for the right combination of modules and tools needed to correctly generate the JavaScript gRPC client and corresponding TypeScript types.
Advanced features: C# includes useful features that are missing from other languages, like the ability to override operators and define destructors.
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:
- TypeScript - top of the stack
- Highest level language, fastest development speed.
- Not the best performance, but still good enough for most applications.
- Dipping down into native code (e.g. for performance) isn't very friendly.
- C# - middle of the stack
- Still high level and garbage collected, so still very easy to work with, although not quite as ergonomic as TypeScript.
- Better performance than TypeScript in terms of both speed and memory usage.
- Perhaps more importantly, it's easy to dip down to the lowest layer of the stack when needed, which brings me to:
- C++ - bottom of the stack
- Not very friendly to develop with (e.g. manual memory management), so development is significantly slower.
- But it has the fastest runtime performance! And it's ubiquitous, so it integrates with an enormous ecosystem of existing software.
- Looks a lot like C# if you squint your eyes, and it has a good standard library, but it's also full of footguns (mostly related to memory management). I'm very interested in using Rust more for memory safety, but a lot of my work involves integrating with existing C++ code bases, so it's easier for me to just default to C++ for now.
Footnotes
- 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. ↩
- 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. ↩