Skip to content

Journey 06 Source Generators

Roger Johansson edited this page Jan 15, 2026 · 3 revisions

Chapter 6: Source Generators

November 30, 2025 - I got tired of typing

The Problem

JavaScript has a lot of built-in methods. Like, a LOT:

  • Array: push, pop, map, filter, reduce, forEach, find, findIndex, includes, indexOf, slice, splice... (30+ more)
  • String: charAt, charCodeAt, concat, includes, indexOf, slice, split, substring, toLowerCase, toUpperCase... (40+ more)
  • Object: keys, values, entries, assign, freeze, seal, create, defineProperty... (20+ more)

Each method needs:

  • Argument validation
  • Type coercion
  • The actual logic
  • Return value wrapping
  • Error handling

Writing this boilerplate for hundreds of methods? No thanks.

The Solution

Roslyn source generators. Write attributes, get code.

[JsPrototype("Array")]
public partial class ArrayPrototype
{
    [JsMethod("push")]
    public static JsValue Push(JsValue thisArg, JsValue[] args)
    {
        var array = thisArg.AsArray();
        foreach (var arg in args)
            array.Add(arg);
        return JsValue.FromNumber(array.Length);
    }

    [JsMethod("pop")]
    public static JsValue Pop(JsValue thisArg, JsValue[] args)
    {
        var array = thisArg.AsArray();
        if (array.Length == 0)
            return JsValue.Undefined;
        var value = array[^1];
        array.RemoveAt(array.Length - 1);
        return value;
    }
}

The source generator sees these attributes and generates all the wiring code:

// Generated by PrototypeSourceGenerator
public partial class ArrayPrototype
{
    public static void Initialize(Realm realm)
    {
        var proto = realm.Intrinsics.ArrayPrototype;

        proto.DefineOwnProperty("push", new PropertyDescriptor(
            value: new JsHostFunction("push", Push, 1),
            writable: true,
            enumerable: false,
            configurable: true
        ));

        proto.DefineOwnProperty("pop", new PropertyDescriptor(
            value: new JsHostFunction("pop", Pop, 0),
            writable: true,
            enumerable: false,
            configurable: true
        ));

        // ... generated for every [JsMethod]
    }
}

Write the logic. Skip the ceremony.

How It Works

Source generators are basically just Roslyn plugins that run at compile time:

Your Code → Roslyn Compiler → Source Generator → Generated Code
                    ↓
            Combined Assembly

The generator:

  1. Finds classes with [JsPrototype]
  2. Finds methods with [JsMethod], [JsGetter], [JsStaticMethod]
  3. Generates initialization code
  4. Outputs .g.cs files that become part of your assembly
[Generator]
public class PrototypeSourceGenerator : ISourceGenerator
{
    public void Execute(GeneratorExecutionContext context)
    {
        // Find [JsPrototype] classes
        // For each, find [JsMethod] methods
        // Generate Initialize() method
        // Output the source
    }
}

The Stats

  • Before: ~50 lines per method (with wiring)
  • After: ~10 lines per method (just the logic)
  • Methods in stdlib: 200+
  • Lines saved: Thousands

Constructors Too

[JsConstructor("Array")]
public partial class ArrayConstructor
{
    [JsConstruct]
    public static JsValue Construct(JsValue newTarget, JsValue[] args)
    {
        if (args.Length == 0)
            return JsValue.FromObject(new JsArray());

        if (args.Length == 1 && args[0].IsNumber)
            return JsValue.FromObject(new JsArray((int)args[0].AsNumber()));

        var array = new JsArray();
        foreach (var arg in args)
            array.Add(arg);
        return JsValue.FromObject(array);
    }

    [JsStaticMethod("isArray")]
    public static JsValue IsArray(JsValue thisArg, JsValue[] args)
    {
        return JsValue.FromBoolean(
            args.Length > 0 && args[0].IsArray
        );
    }
}

The generator wires up:

  • [[Construct]] behavior (called with new)
  • [[Call]] behavior (called without new)
  • Static methods on the constructor function

Type Safety

The generator validates at compile time:

// Error: Method must be static
[JsMethod("push")]
public JsValue Push(...) { }  // Missing 'static'

// Error: Wrong signature
[JsMethod("push")]
public static void Push() { }  // Must return JsValue

Mistakes caught before runtime. Beautiful.

Why This Matters

Implementing a JavaScript stdlib is a slog. You're writing the same patterns over and over:

  1. Check argument count
  2. Coerce this to the right type
  3. Coerce arguments
  4. Do the thing
  5. Return a JsValue

Source generators let us focus on step 4 and automate the rest.

The Intl API

The Internationalization API was a perfect fit:

[JsPrototype("Intl.NumberFormat")]
public partial class NumberFormatPrototype
{
    [JsMethod("format")]
    public static JsValue Format(JsValue thisArg, JsValue[] args)
    {
        var formatter = thisArg.GetInternalSlot<NumberFormatter>();
        var number = args.Length > 0 ? args[0].ToNumber() : double.NaN;
        return JsValue.FromString(formatter.Format(number));
    }

    [JsGetter("resolvedOptions")]
    public static JsValue GetResolvedOptions(JsValue thisArg)
    {
        // Return the options object
    }
}

All the locale handling, format strings, and options parsing in one place. The wiring is invisible.

Lessons

  1. Automate the boring stuff - Generators are perfect for boilerplate
  2. Compile-time > runtime - Catch mistakes early
  3. Attribute-based APIs are clean - Mark it, it works
  4. Consistency for free - Generated code follows the same pattern every time

In short: writing Asynkron.JsEngine.Generators took a couple days. It's saved weeks of boilerplate since.


Previous: Chapter 5: TypedAST Next: Chapter 7: Async Remake - Time to admit CPS wasn't cutting it

//Roger

Clone this wiki locally