← Back to Blog Our Take

Go Over C#? The TypeScript Team's Surprising but Practical Choice

November 28, 2024 By Spencer Sharp
Go Over C#? The TypeScript Team's Surprising but Practical Choice

When Microsoft’s TypeScript team needed to implement parts of their Language Server Protocol (LSP) tooling, they made a choice that raised eyebrows across the developer community: they chose Go over Microsoft’s own C#. This decision offers fascinating insights into modern language selection and the pragmatic considerations that drive technical decisions at scale.

The Context: TypeScript’s Growing Ecosystem

TypeScript has evolved from a JavaScript superset into a cornerstone of modern web development. Its tooling ecosystem, particularly the Language Server Protocol implementation, needs to be:

  • Lightning fast for real-time code analysis
  • Memory efficient for large codebases
  • Cross-platform without dependencies
  • Easy to distribute as standalone binaries

Why Not C#?

Given that both TypeScript and C# are Microsoft technologies, C# seemed like the obvious choice. After all, C# offers:

// C# has excellent async support
public async Task<CompletionList> GetCompletionsAsync(Position position)
{
    var analysis = await AnalyzeDocumentAsync(position);
    return GenerateCompletions(analysis);
}

// Strong typing and LINQ for code analysis
var symbols = document.SyntaxTree
    .DescendantNodes()
    .OfType<MethodDeclaration>()
    .Where(m => m.IsPublic)
    .Select(m => new Symbol(m));

However, the team identified several constraints:

1. Deployment Complexity

C# applications traditionally required the .NET runtime, adding complexity to distribution:

# Traditional C# deployment
dotnet publish -c Release -r win-x64
dotnet publish -c Release -r linux-x64
dotnet publish -c Release -r osx-x64

# Each platform needs runtime or self-contained package
# Self-contained packages are large (50-100MB+)

2. Startup Performance

The CLR initialization overhead, while minimal for long-running applications, becomes noticeable for CLI tools that start frequently:

// C# startup includes:
// - CLR initialization
// - JIT compilation
// - Assembly loading
// - Dependency injection container setup
var stopwatch = Stopwatch.StartNew();
var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(services => {
        // Service registration
    })
    .Build();
Console.WriteLine($"Startup: {stopwatch.ElapsedMilliseconds}ms");
// Typically 100-300ms for complex apps

Enter Go: The Practical Choice

Go emerged as the practical winner for several compelling reasons:

1. Single Binary Distribution

Go compiles to standalone binaries with zero runtime dependencies:

// main.go
package main

import (
    "fmt"
    "github.com/typescript/lsp/server"
)

func main() {
    srv := server.New()
    if err := srv.Start(); err != nil {
        fmt.Printf("Server failed: %v\n", err)
    }
}
# Build for all platforms
GOOS=windows GOARCH=amd64 go build -o lsp-win.exe
GOOS=linux GOARCH=amd64 go build -o lsp-linux
GOOS=darwin GOARCH=amd64 go build -o lsp-mac

# Result: ~10-15MB standalone binaries

2. Exceptional Startup Performance

Go binaries start almost instantly:

package main

import (
    "fmt"
    "time"
)

func main() {
    start := time.Now()
    // Initialize server components
    server := InitializeServer()
    fmt.Printf("Ready in %v\n", time.Since(start))
    // Typically < 10ms
    server.Run()
}

3. Built-in Concurrency

Go’s goroutines and channels make concurrent operations trivial:

// Parallel document analysis
func AnalyzeWorkspace(files []string) []Diagnostic {
    diagnosticsChan := make(chan []Diagnostic, len(files))
    
    for _, file := range files {
        go func(f string) {
            diagnostics := analyzeFile(f)
            diagnosticsChan <- diagnostics
        }(file)
    }
    
    // Collect results
    var allDiagnostics []Diagnostic
    for i := 0; i < len(files); i++ {
        allDiagnostics = append(allDiagnostics, <-diagnosticsChan...)
    }
    return allDiagnostics
}

4. Memory Efficiency

Go’s garbage collector is optimized for low latency:

// Efficient memory usage with object pools
var bufferPool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 0, 4096)
    },
}

func ProcessDocument(content []byte) {
    buffer := bufferPool.Get().([]byte)
    defer bufferPool.Put(buffer[:0])
    
    // Use buffer for processing
    // Memory is reused, reducing GC pressure
}

Real-World Performance Comparison

Here’s a practical comparison of implementing a simple LSP feature:

C# Implementation

public class CompletionHandler : ICompletionHandler
{
    private readonly ILogger<CompletionHandler> _logger;
    private readonly IDocumentService _documents;
    
    public CompletionHandler(ILogger<CompletionHandler> logger, 
                           IDocumentService documents)
    {
        _logger = logger;
        _documents = documents;
    }
    
    public async Task<CompletionList> HandleAsync(
        CompletionParams request, 
        CancellationToken cancellationToken)
    {
        var sw = Stopwatch.StartNew();
        var document = await _documents.GetAsync(request.TextDocument.Uri);
        var completions = await GenerateCompletionsAsync(
            document, 
            request.Position,
            cancellationToken);
        
        _logger.LogDebug($"Completions generated in {sw.ElapsedMilliseconds}ms");
        return completions;
    }
}

Go Implementation

type CompletionHandler struct {
    documents DocumentService
}

func (h *CompletionHandler) Handle(params CompletionParams) (*CompletionList, error) {
    start := time.Now()
    
    doc, err := h.documents.Get(params.TextDocument.URI)
    if err != nil {
        return nil, err
    }
    
    completions := h.generateCompletions(doc, params.Position)
    
    log.Printf("Completions generated in %v", time.Since(start))
    return completions, nil
}

// Benchmarks show:
// - Go: 2-5ms average response time
// - C#: 10-20ms average response time (including JIT warmup)
// - Binary size: Go 12MB vs C# 65MB (self-contained)
// - Memory usage: Go 50MB vs C# 150MB for typical workload

The Broader Implications

1. Language Ecosystem Maturity

Go’s selection demonstrates that language ecosystems matter more than corporate allegiance:

// Rich ecosystem for development tools
import (
    "go/ast"     // AST manipulation
    "go/parser"  // Code parsing
    "go/token"   // Token handling
    "gopkg.in/yaml.v3"  // Configuration
    "github.com/gorilla/websocket"  // WebSocket support
)

2. Cross-Platform Development Reality

Modern tools must work seamlessly across platforms:

// Platform-specific code is minimal
func getPlatformSpecificPath() string {
    switch runtime.GOOS {
    case "windows":
        return filepath.Join(os.Getenv("APPDATA"), "typescript-lsp")
    case "darwin":
        return filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "typescript-lsp")
    default:
        return filepath.Join(os.Getenv("HOME"), ".config", "typescript-lsp")
    }
}

3. The Right Tool for the Job

Microsoft’s decision shows technical pragmatism over dogma:

  • Web APIs: C# with ASP.NET Core excels
  • Desktop Applications: C# with WPF/WinUI shines
  • Command-Line Tools: Go often wins
  • System Programming: Rust gaining ground

Lessons for Developers

1. Evaluate Based on Requirements

# Decision matrix example
Language Server Requirements:
  startup_time: critical
  binary_size: important
  memory_usage: important
  ecosystem: important
  cross_platform: critical
  
Scoring:
  Go:
    startup_time: 10/10
    binary_size: 9/10
    memory_usage: 8/10
    ecosystem: 8/10
    cross_platform: 10/10
    
  C#:
    startup_time: 6/10
    binary_size: 5/10
    memory_usage: 7/10
    ecosystem: 9/10
    cross_platform: 8/10

2. Consider Operational Aspects

Development speed isn’t everything. Consider:

  • Deployment complexity
  • Runtime dependencies
  • Operational overhead
  • End-user experience

3. Embrace Polyglot Architecture

Modern applications often benefit from multiple languages:

{
  "frontend": "TypeScript + React",
  "backend_api": "C# + ASP.NET Core",
  "cli_tools": "Go",
  "data_processing": "Python",
  "infrastructure": "Terraform + Go"
}

Conclusion

The TypeScript team’s choice of Go over C# isn’t a rejection of C# or .NET—it’s a recognition that different problems require different tools. Go’s strengths in producing small, fast, standalone binaries made it the pragmatic choice for developer tooling.

This decision offers valuable lessons:

  1. Technical decisions should be driven by requirements, not politics
  2. Modern development is increasingly polyglot
  3. Operational characteristics matter as much as development experience
  4. The best language is the one that solves your specific problem

As developers, we should celebrate having such excellent options and the wisdom to choose the right tool for each job. Whether it’s Go’s simplicity, C#‘s rich framework, or another language entirely, the key is matching the solution to the problem.

The real winner? Developers who get fast, reliable tools regardless of the implementation language.

Ready to Build Something Amazing?

Let's discuss how Aviron Labs can help bring your ideas to life with custom software solutions.

Get in Touch