Skip to content

18 · Modules

C# with extern

Typed functions, closures and .NET objects called like Zolo code.

On this page

extern uses an explicitly imported provider. The dotnet plugin exports csharp. Parameters and return types use Zolo syntax; the body uses the provider's language.

Declare a C# function and call it normally, passing a Zolo closure as a callback. C# can also return a closure with state. int preserves all 64 bits; extern csharp async fn returns a Future integrated with Zolo's scheduler and Promise combinators.

14-extern-csharp.zolo
Playground
// expected:

// 42

// 9223372036854775807

// Zolo

// 11

// 12

// 42

// 40

// 42


use plugin dotnet::{csharp}
use std::async::Promise

extern csharp fn add(a: int, b: int) -> int {
    return a + b;
}

extern csharp fn exact(value: int) -> int {
    return value;
}

extern csharp fn counter(start: int) -> fn() -> int {
    var value = start;
    return () => System.Threading.Interlocked.Increment(ref value);
}

extern csharp fn apply(value: int, transform: fn(int) -> int) -> int {
    return transform(value);
}

extern csharp async fn twice(value: int) -> int {
    await System.Threading.Tasks.Task.Delay(10);
    return value * 2;
}

print(add(20, 22))
print(exact(9223372036854775807))

let original = "  Zolo  "
let clean: str = extern csharp {
    return original.Trim();
}
print(clean)

let next = counter(10)
print(next())
print(next())

print(apply(20) { |value| add(value, 22) })

let results = await Promise.all([twice(20), twice(21)])
print(results[0])
print(results[1])

An expression block captures the Zolo variables referenced by its C# body. The variable's annotation supplies the expected result type:

use plugin dotnet::{csharp}

let original = "  Zolo  "
let clean: str = extern csharp {
    return original.Trim();
}
print(clean)

For a small function, put native C# imports at the beginning of the body:

extern csharp fn byte_count(text: str) -> int {
    using System.Text;
    return Encoding.UTF8.GetByteCount(text);
}

Use namespace imports, aliases and using static in functions or expression blocks. Each binding has its own imported names. Imports come before executable statements; resource using var, using (...) and await using retain ordinary C# scope and disposal. The editor understands both imports and the body.

22-extern-native-imports.zolo
Playground
// Native imports belong at the start of the foreign fragment.

use plugin dotnet::{csharp}

extern csharp fn byte_count(text: str) -> int {
    using System.Text;
    return Encoding.UTF8.GetByteCount(text);
}

// The same alias can mean something different in another binding.

extern csharp fn alias_bytes(text: str) -> int {
    using Native = System.Text.Encoding;
    return Native.UTF8.GetByteCount(text);
}

extern csharp fn alias_abs(value: int) -> int {
    using Native = System.Math;
    return Native.Abs(value);
}

extern csharp async fn later(value: int) -> int {
    using static System.Math;
    using Tasks = System.Threading.Tasks.Task;
    await Tasks.Delay(1);
    return Abs(value);
}

extern csharp async fn alias_namespace() -> int {
    using System = global::System.Collections.Generic;
    await global::System.Threading.Tasks.Task.Delay(1);
    return new System::List<int> { 42 }[0];
}

// Resource usings stay in the method and dispose at their ordinary scope.

extern csharp fn resource_declaration() -> int {
    using System.IO;
    using var stream = new MemoryStream(new byte[] { 1, 2, 3, 4 });
    return stream.Length;
}

extern csharp fn resource_statement() -> int {
    using Stream = System.IO.MemoryStream;
    using (var stream = new Stream(new byte[] { 1, 2, 3, 4 })) {
        return stream.Length;
    }
}

extern csharp async fn async_resource() -> int {
    using System.IO;
    await using var stream = new MemoryStream(new byte[] { 1, 2, 3, 4 });
    return stream.Length;
}

print(byte_count("Zolo"))
print(alias_bytes("Zolo"))
print(alias_abs(-42))
print(await later(-42))
print(await alias_namespace())
print(resource_declaration())
print(resource_statement())
print(await async_resource())

let original = " Zolo "
let clean: str = extern csharp {
    using Regex = System.Text.RegularExpressions.Regex;
    return Regex.Replace(original, @"\s", "");
}
print(clean)

extern csharp {
    fn grouped(value: int) -> int {
        using static System.Math;
        return Abs(value);
    }
}
print(grouped(-42))

// expected:

// 4

// 4

// 42

// 42

// 42

// 4

// 4

// 4

// Zolo

// 42

extern csharp mod exposes a C# module's public types. Constructors, methods and properties receive typed bindings from .NET metadata. Objects keep their identity and state when a method returns them.

Construct a counter, change a property, choose overloads by argument type and subscribe to an event with a Zolo closure. Close the subscription when done and call Dispose for deterministic cleanup.

15-extern-objects.zolo
Playground
// expected:

// 5

// 41

// true

// 42

// true

// true

// 43

// number:7

// text:Zolo

// 44

// 44

// 45

// true

// 42

// i32

// i32

// int

// int

// 42

// ok


use plugin dotnet::{csharp}

extern csharp mod counters {
    public sealed class Counter : IDisposable {
        public long Value { get; set; }
        public bool Disposed { get; private set; }
        public event Action<long>? Changed;

        public Counter(long start = 5) { Value = start; }
        public long Next() {
            if (Disposed) throw new ObjectDisposedException(nameof(Counter));
            var value = ++Value;
            Changed?.Invoke(value);
            return value;
        }
        public Counter Self() => this;
        public Counter? Optional(bool present) => present ? this : null;
        public Counter[] Many() => new[] { this };
        public string Format(long value) => "number:" + value;
        public string Format(string value) => "text:" + value;
        public void Dispose() { Disposed = true; Changed = null; }
    }

    public sealed class Keeper {
        private readonly Func<long, long> callback;
        public Keeper(Func<long, long> callback) { this.callback = callback; }
        public long Run() {
            GC.Collect();
            GC.WaitForPendingFinalizers();
            return callback(41);
        }
    }

    public sealed class Pick {
        public string Kind;
        public Pick(int value) { Kind = "i32"; }
        public Pick(long value) { Kind = "int"; }
        public string Get(int value) => "i32";
        public string Get(long value) => "int";
        public static string Static(int value) => "ok";
    }

    public class Base {
        public long Answer() => 42;
    }
    public sealed class Derived : Base { }
}

let counter = counters.Counter()
print(counter.Value)
counter.Value = 40
print(counter.Next())
let same = counter.Self()
print(same == counter)
print(same.Next())
print(counter.Optional(false) == nil)
print(counter.Optional(true) == counter)
print(counter.Many()[0].Next())
print(counter.Format(7))
print(counter.Format("Zolo"))

let subscription = counter.on_Changed(|value: int| print(value))
print(counter.Next())
subscription.close()
print(counter.Next())
counter.Dispose()
print(counter.Disposed)

let keeper = counters.Keeper(|value: int| value + 1)
print(keeper.Run())
let narrow_value: i32 = 1
let narrow = counters.Pick(narrow_value)
print(narrow.Kind)
print(narrow.Get(narrow_value))
let wide = counters.Pick(1)
print(wide.Kind)
print(wide.Get(1))
print(counters.Derived().Answer())
print(counters.Pick.Static(narrow_value))

C# awaits callbacks returning Future, while Zolo awaits .NET Task. Handle errors in both directions with try/catch and cancel an in-flight operation with Future.cancel(). Inside C#, cancellation is the operation's cooperative cancellation token.

16-extern-async.zolo
Playground
// expected:

// 42

// 43

// callback caught

// CLR caught

// true


use plugin dotnet::{csharp}
use std::async::Promise

extern csharp async fn transform(value: int, callback: fn(int) -> Future<int>) -> int {
    return await callback(value);
}

extern csharp async fn fail() -> int {
    await Task.Delay(5);
    throw new InvalidOperationException("outer", new ArgumentException("inner"));
}

extern csharp async fn wait_for_cancel() -> int {
    await Task.Delay(60000, cancellation);
    return 0;
}

async fn increment(value: int) -> int {
    sleep 2ms
    return value + 1
}

async fn reject(value: int) -> int {
    sleep 1ms
    panic("callback failed")
    return value
}

async fn cancel_soon(future: Future<int>) -> bool {
    sleep 5ms
    future.cancel()
    return future.is_done()
}

let values = await Promise.all([
    transform(41, increment),
    transform(42, increment),
])
print(values[0])
print(values[1])

let callback_result = try { await transform(0, reject) } catch error { "callback caught" }
print(callback_result)
let clr_result = try { await fail() } catch error { "CLR caught" }
print(clr_result)

let pending = wait_for_cancel()
print(await cancel_soon(pending))

Source files, assemblies and projects share one import form:

extern csharp from "./Counter.cs" use {Counter}
extern csharp from "./Legacy.dll" use Acme::Tools::{Formatter}
extern csharp from "./Services/Services.csproj" use Acme::Services::{Client}
extern csharp use System::Text::{StringBuilder}

Pass structs as typed DTOs, preserve empty arrays and nullable slots, and transfer bytes. The tests also cover exact 64-bit integers and floats.

17-extern-data.zolo
Playground
// expected:

// 9223372036854775807

// true

// 3

// 7

// true

// 9

// 0

// 3

// Zolo

// 42

// Hi Zolo

// Zolo

// true

// Double


use plugin dotnet::{csharp}

// An unrelated generic must not become an invalid DTO in every extern body.

struct Box<T> { value: T }
struct Payload { value: int, optional: int?, items: [int] }

extern csharp {
    fn echo(input: Payload) -> Payload { return input; }
    fn nullable(values: [int?]) -> [int?] { return values; }
    fn empty() -> [int] { return Array.Empty<long>(); }
    fn create_builder() -> Any { return new System.Text.StringBuilder(); }
}

let copy = echo(Payload {
    value: 9223372036854775807,
    optional: nil,
    items: [7, 8, 9],
})
print(copy.value)
print(copy.optional == nil)
print(copy.items.len())
let values = nullable([7, nil, 9])
print(values[0])
print(values[1] == nil)
print(values[2])
print(empty().len())
print(nullable([7, nil, nil]).len())

let original = "Zolo"
let clean: str = extern csharp { return original.Trim(); }
print(clean)
let run = |value: int| extern csharp { return value + 1; }
print(run(41))
let greeting: str = extern csharp { return $"Hi {original}"; }
print(greeting)

// The dynamic API uses a live proxy for types absent from a typed import.

let builder = create_builder()
builder.Append("Zolo")
print(builder.ToString())

extern csharp fn is_negative_zero(value: float) -> bool {
    return double.IsNegativeInfinity(1.0 / value);
}
extern csharp fn clr_type(value: Any) -> str { return value.GetType().Name; }
print(is_negative_zero(-0.0))
print(clr_type(1.0))

Import C# source, a DLL, a project with dependencies and framework types. Builds carry the required artifacts; deployed executables need neither the original source files nor the SDK.

18-extern-imports.zolo
Playground
// expected:

// i32

// i32

// int

// 42

// 42

// 42

// number:7

// text:Zolo

// 42

// 42

// Zolo


use plugin dotnet::{csharp}

extern csharp from "./extern-fixtures/Source.cs" use Demo::Sources::{Calculator}
extern csharp from "./extern-fixtures/Legacy/bin/Release/net8.0/Legacy.dll" use Demo::Legacy::{Formatter}
extern csharp from "./extern-fixtures/Services/Services.csproj" use Demo::Services::{Client}
extern csharp use System::Text::{StringBuilder}

extern csharp fn read(client: Client) -> int { return client.Compute(40); }
extern csharp from "./extern-fixtures/Legacy/bin/Release/net8.0/Legacy.dll" fn number(value: int) -> str = Demo.Legacy.Formatter.Format

let narrow_value: i32 = 41
let narrow = Calculator(narrow_value)
print(narrow.Kind)
print(narrow.Get(narrow_value))
print(narrow.Get(1))
print(narrow.Add(1))
print(await narrow.Later(1))
let work = narrow.CreateWork()
print(await work(1))
print(number(7))
print(Formatter.Format("Zolo"))
let client = Client()
print(client.Compute(40))
print(read(client))
let builder = StringBuilder()
builder.Append("Zolo")
print(builder.ToString())

Before running this example directly, build its sample DLL: dotnet build examples/features/14-modules/extern-fixtures/Legacy/Legacy.csproj -c Release.

A .NET timer can wake a Zolo receiver parked on a channel. Callback failures use ordinary try/catch; Dispose ends the resource and lets the scheduler detect a channel with no remaining source of progress.

19-extern-background.zolo
Playground
// expected:

// 42

// 43

// timer finished

// 42

// 42

// timer caught

// disposed timer

// 42


use plugin dotnet::{csharp}

// The CLR retains these callbacks after start() returns. Their bodies still

// execute on the owning Zolo runtime thread, including the async callback.

extern csharp fn start(callback: fn(int) -> nil) -> Any {
    return new System.Threading.Timer(_ => callback(42), null, 20, -1);
}
extern csharp fn start_async(callback: fn(int) -> Future<int>) -> Any {
    return new System.Threading.Timer(async _ => await callback(42), null, 40, -1);
}
async fn increment(value: int) -> int {
    sleep 1ms
    print(value + 1)
    return value + 1
}

let first = start(|value| { print(value) })
let second = start_async(increment)
sleep 100ms
first.Dispose()
second.Dispose()
print("timer finished")

// A retained CLR callback is a possible waker even when all Zolo tasks

// are parked on a channel and there are no Zolo timers left to run.

let scoped = channel(1)
let scoped_timer = start(|value| { scoped.send(value) })
scope {
    spawn { print(scoped.recv().unwrap()) }
}
scoped_timer.Dispose()

let global = channel(1)
let global_timer = start(|value| { global.send(value) })
async fn receive() -> int { return global.recv().unwrap() }
print(await receive())
global_timer.Dispose()

let failed = channel(1)
let failed_timer = start(|_value| { panic("background callback failed") })
let result = try {
    scope { spawn { failed.recv().unwrap() } }
    "unexpected success"
} catch error { "timer caught" }
print(result)
failed_timer.Dispose()
// Disposed CLR resources retain their delegates for GC, but cannot wake a

// parked task. This must diagnose a deadlock and leave the scheduler usable.

let disposed_timer = start(|_value| {})
disposed_timer.Dispose()
let abandoned = channel(1)
let disposed_result = try {
    scope { spawn { abandoned.recv().unwrap() } }
    "unexpected success"
} catch error { "disposed timer" }
print(disposed_result)
async fn recover() -> int { sleep 1ms; return 42 }
print(await recover())

Export a .NET type through a Zolo file and import it under an alias elsewhere. Constructors, factories, properties and overloads retain their types.

21-extern-module-imports.zolo
Playground
// expected:

// zero

// i32

// i32

// int

// text:Zolo

// static:i32

// static:int

// str-Any

// str-Any

// text:again

// text:factory

// i32


use extern_fixtures::api::{self as tools, Pick as Counter, make}

let zero = Counter()
print(zero.Kind)
let narrow_value: i32 = 1
let narrow = Counter(narrow_value)
print(narrow.Kind)
print(narrow.Get(narrow_value))
print(narrow.Get(1))
print(narrow.Get("Zolo"))
print(Counter.Static(value: narrow_value))
print(Counter.Static(1))
let dynamic: Any = 7
print(narrow.Named(y: dynamic, x: "Zolo"))
print(narrow.Named("Zolo", dynamic))
print(narrow.Self().Get("again"))
let created = make()
print(created.Get("factory"))
let qualified = tools.Pick(narrow_value)
print(qualified.Get(narrow_value))

The editor uses Roslyn for C# diagnostics at the original source location, completion and hover. The provider requires .NET 8 or later. Projects need the SDK at build time; NuGet dependencies need packages.lock.json. VM, native and LLVM support this provider. A WebAssembly build without a .NET host reports that the provider is unavailable.

Global index

Find your way through Zolo

Try an idea

Start here

9 results

9 results

enpt-br