u/marna_li

Raven — is this the Kotlin moment for .NET?

I had some help from AI putting this post together and organizing my thoughts.

TL;DR: I've been building Raven, a modern programming language for .NET. It combines familiar .NET semantics and interoperability with ideas from languages such as Swift, Kotlin and Rust: unions and pattern matching, Option/Result, propagation, expression-oriented control flow, macros for building DSLs, and more.

It now has a browser playground, an SDK/compiler distribution, a language server, and a VS Code extension.

Playground (with samples): https://marinasundstrom.github.io/raven/playground/

Latest preview: https://github.com/marinasundstrom/raven/releases/tag/v0.1.0-preview.10

Background

For the last couple of years I've been building my own compiler and programming language, mainly for my own amusement. This isn't my first venture into compiler construction, but it has probably been the most creative and fulfilling one.

Raven started out much closer to C#, but gradually developed its own identity as I explored other languages and different approaches to language design. I never wanted to make "C# with different syntax," nor simply copy another language.

What emerged is something that feels at home on .NET, but with a syntax somewhat reminiscent of Swift and ideas influenced by Kotlin and Rust.

A lot has been tried and discarded along the way. I had an early implementation of union types before eventually aligning Raven with the nominal union model being introduced in C#/.NET — and then taking that model further. I experimented with trailing blocks before eventually removing them in favor of a macro system for DSLs. Error handling evolved toward Result and Option, while still retaining pragmatic interoperability with .NET exceptions and nullability.

The compiler itself uses a Roslyn-like compiler-as-a-service architecture. If you've worked with the C# compiler APIs, much of it should feel surprisingly familiar: immutable syntax trees, compilations, symbols, semantic models, and an Operations API providing a higher-level semantic representation.

Raven primarily targets .NET 11, while also supporting .NET 10.

AI has also had a significant role in the development process. Initially I mostly used it for research and finding examples. Over time I moved toward using coding agents extensively for implementation. That has made it possible to iterate unusually quickly, including making large architectural changes while simultaneously building out automated tests and custom compiler debugging infrastructure.

For the last few weeks Raven has had a playground running the compiler entirely in the browser through WebAssembly. Now there is finally a distributable SDK containing the compiler and language server, together with a VS Code extension.

Raven has grown broad enough that it's difficult to represent the language with one clever code sample, so instead I'll start with some of the fundamentals.

Hello, world

HelloWorld.rvn:

import System.Console.*

func Main() {
    WriteLine("Hello, from Raven!")
}

Like C#, Raven supports global imports, so commonly used .NET namespaces can already be available without explicitly importing them.

The syntax is different, but this is still very much a .NET language. Raven consumes .NET libraries and types directly rather than building a separate ecosystem alongside them.

Language reference: https://marinasundstrom.github.io/raven/lang/spec/index.html

Lexical bindings

Raven uses immutable bindings by default. Values declared with let cannot be reassigned:

let name = "Raven"
let count = 10

When mutable state is actually needed, you opt into it using var

var count = 0
count = count + 1

Types are normally inferred, but can also be specified explicitly:

let name: string = "Raven"
var count: int = 0

This distinction also carries into pattern matching and other language constructs: let means binding a value, rather than declaring a mutable variable.

Functions

Raven also supports namespace-scoped functions. Functions don't need to be declared as static members of a class:

namespace Inventory

func CalculateTotal(quantity: int, price: decimal) -> decimal {
    return quantity * price
}

They are ordinary namespace members and can form part of an assembly's API just like types. As with other namespace-level declarations, they are internal by default and can explicitly be made public:

public func CalculateTotal(quantity: int, price: decimal) -> decimal {
    return quantity * price
}

Option, Result and propagation

Raven has built-in Option<T> and Result<T, E> unions for modeling optionality and operations that can fail.

Raven does not pretend that null or exceptions don't exist. It has a unified nullability model and supports exceptions where appropriate, particularly for .NET interoperability. Option and Result are additional tools for cases where absence or failure are part of the domain model.

For example:

func ReserveSeats(requested: int, available: int) -&gt; Result&lt;int, string&gt; {
    if requested &lt;= 0 {
        return Error("Choose at least one seat")
    }

    if requested &gt; available {
        return Error("Only $available seats remain")
    }

    return Ok(requested)
}

func PriceBooking(
    requested: int,
    available: int,
    pricePerSeat: int
) -&gt; Result&lt;int, string&gt; {
    let seats = ReserveSeats(requested, available)?
    return Ok(seats * pricePerSeat)
}

match PriceBooking(requested: 3, available: 5, pricePerSeat: 40) {
    Ok(let total) =&gt;
        Console.WriteLine("Booking total: $total credits")

    Error(let message) =&gt;
        Console.WriteLine("Problem: $message")
}

The postfix ? propagates the failure while extracting the successful value.

This isn't hard-coded specifically to Result, either. Raven has a propagation contract, so custom types can participate in the same mechanism, including conversion between compatible residual/error types.

Unions and domain modeling

You can define your own unions:

union StockError {
    case UnknownSku(sku: string)
    case InsufficientStock(
        sku: string,
        requested: int,
        available: int
    )
}

Cases can carry data and participate directly in pattern matching.
Raven also supports the more explicit form:

union StockError(UnknownSku | InsufficientStock)

where the variants are separately declared records:

 record UnknownSku(val Sku: string)

 record InsufficientStock(
     val Sku: string
     val Requested: int
     val Available: int
 )

One of Raven's main design goals is making this kind of domain modeling natural rather than treating unions as an isolated pattern-matching feature.

Raven also supports closed (sealed) class hierarchies, providing another way to model a closed set of alternatives while retaining class inheritance.

Statements and expressions

Many of Raven's common control-flow constructs have both statement and expression forms. You can use them for ordinary control flow, or use the value they produce directly.

For example, if can be used as a statement

if temperature &gt; 25 {
    Console.WriteLine("It's warm")
} else {
    Console.WriteLine("It's cold")
}

or as an expression:

let description =
    if temperature &gt; 25 { "warm" }
    else { "cold" }

The same idea applies to match:

let message = match result {
    Ok(let value) =&gt; "Received $value"
    Error(let error) =&gt; "Failed: $error"
}

This is part of a broader design choice in Raven: control flow shouldn't require a completely different construct just because you want to produce a value from it.

Raven also provides pattern-oriented forms such as if let and let else for cases where control flow and destructuring naturally belong together.

func FindFirstEven(numbers: int[]) -&gt; Option&lt;int&gt; {
    for number in numbers {
        if number % 2 == 0 {
            return Some(number)
        }
    }

    return None
}


func DescribeFirstEven(numbers: int[]) -&gt; string {
    let Some(number) = FindFirstEven(numbers) else {
        return "No even number found"
    }

    return "The first even number is $number"
}


Console.WriteLine(DescribeFirstEven([1, 3, 8, 13]))

Visibility

You might also notice the absence of access modifiers in most examples.

Raven deliberately makes the common cases terse:

  • Type members are public by default.
  • Type members can explicitly be made private.
  • Namespace-level declarations are internal by default.
  • Declarations intended to form part of the assembly's public API are explicitly marked public.
  • So a library naturally keeps its top-level API internal until you deliberately expose it, while the members of the types you do expose don't require public everywhere.

Macros and DSLs

Another major part of Raven is its macro system.

Rather than adding specialized syntax to the language for every possible domain, Raven allows libraries and frameworks to provide domain-specific syntax through macros.

For example, Raven has an HTML macro that can be used when building Blazor applications:

Html! {
    &lt;div class="counter"&gt;
        &lt;h1&gt;Counter&lt;/h1&gt;

        &lt;p&gt;Current count: {count}&lt;/p&gt;

        &lt;button onclick={IncrementCount}&gt;
            Click me
        &lt;/button&gt;
    &lt;/div&gt;
}

This isn't a separate template language bolted onto Raven. The macro is expanded by the compiler and can produce the corresponding Blazor representation.

The syntax is deliberately more JSX-like than Razor-like: when you are inside the HTML macro, you are writing HTML until you explicitly enter a Raven expression.

Try it out here: https://marinasundstrom.github.io/raven/experiments/html-macro/

Macros also integrate with the compiler infrastructure and language server, so DSLs don't have to mean giving up editor tooling.

This replaced some earlier experiments I had with special language features such as trailing blocks. I increasingly prefer keeping the core language relatively general and letting macros provide domain-specific abstractions where they make sense.

Raven beyond console applications

Raven isn't limited to small compiler demos anymore.

You can already build web applications with Raven using ASP.NET Core and Blazor. Because Raven targets .NET and consumes .NET APIs directly, the existing .NET ecosystem remains available rather than requiring Raven-specific replacements for everything.

Sample projects: https://github.com/marinasundstrom/raven/tree/main/samples/projects

At the other end of the spectrum, Raven can also target .NET nanoFramework, including its experimental generics support, which means the same language can be used for constrained embedded and IoT applications.

For example, a nanoFramework program controlling a GPIO pin looks like this:

import System.Device.Gpio.*
import System.Threading.*

func Main() {
    use gpio = GpioController()
    use led = gpio.OpenPin(25, PinMode.Output)

    loop {
        led.Write(PinValue.High)
        Thread.Sleep(500)

        led.Write(PinValue.Low)
        Thread.Sleep(500)
    }
}

That runs in a very different environment from an ASP.NET Core application, but it's still Raven.

Raven also supports Native AOT on the regular .NET target, so applications can be compiled ahead of time into native executables rather than requiring JIT compilation at runtime.

That gives Raven a fairly interesting range already:

  • regular .NET applications and libraries
  • ASP.NET Core and Blazor web applications
  • Native AOT applications
  • embedded/IoT applications through .NET nanoFramework
  • WebAssembly, which is also how the Raven playground runs the compiler itself in the browser

This is an important part of what I want Raven to be. I'm not particularly interested in creating a language that only looks nice in isolated examples. The interesting question is whether a language can make substantially different choices from C# while still taking advantage of the enormous runtime, library and tooling ecosystem that already exists around .NET.

So, a Kotlin moment for .NET?

That's increasingly how I've started thinking about the experiment.

Not as a replacement for C#. Kotlin didn't need Java to disappear to justify its existence either.

The interesting proposition is: what if you keep .NET, but change the language?
Keep the runtime. Keep the libraries. Keep NuGet. Keep ASP.NET Core and Blazor. Keep the ability to target everything from servers and WebAssembly to Native AOT and tiny embedded devices.

But rethink some of the language-level choices: make unions and pattern matching fundamental, make Option and Result natural ways of modeling absence and failure, make control flow more expression-oriented, and provide macros so that libraries can build abstractions and DSLs that don't have to become new language features.

That's the space Raven is exploring.

Website: https://marinasundstrom.github.io/raven

reddit.com
u/marna_li — 4 days ago