Skip to content
Alireza Janaki edited this page Aug 23, 2026 · 7 revisions

H Sharp (H#) Language Guide

H# looks like C# and handles memory like Rust: no garbage collector, nothing leaks, everything on the heap is freed the moment it stops being used, checked at compile time, costing nothing at runtime. Source files use the .hs extension and compile to optimized native executables through LLVM.

Setup

You need on your PATH: the .NET 8 SDK (builds the compiler), LLVM 18 (LLVM-C.dll on Windows, libLLVM-18 on Linux/macOS) and clang (links the exe).

dotnet run --project src/HSharp/compiler -- hello.hs   # produces hello.exe / hello
./hello.exe

That's the whole workflow: edit, compile, run. Top-level statements are the program, no main function, no boilerplate.

Variables and types

var x = 10;                 // int
var pi = 3.5;               // float
var flag = true;            // bool
var name = "Ann";           // string
var nums = list<int> { 1, 2, 3 };

string label = "explicit";  // optional type annotation

int widens to float automatically (1 + 2.5 is 3.5), never the other way. Numbers become strings on their own when concatenated or interpolated. Operators: + - * / %, comparisons == != < <= > >=, logic && || ! (short-circuit), plus ++/-- and += -= *= /= %=. Conditions must be real bools, if (1) won't compile. Strings compare with == and != only. Comments are // and /* */. Identifiers are case-sensitive.

Control flow

if (x > 5)        { print("big"); }
else if (x == 5)  { print("five"); }
else              { print("small"); }

var i = 0;
while (i < 3)
{
    if (i == 1) { i++; continue; }
    print(i);
    if (i == 5) { break; }
    i++;
}

for (var j = 0; j < 5; j++) { sum += j; }
foreach (var n in nums)     { print(n); }

break and continue work in all three loop forms, and any values still alive inside the loop are freed correctly on the way out.

Functions

int add(int a, int b)
{
    return a + b;
}

string tag(string prefix, int id)
{
    return $"{prefix}#{id}";
}

void log(move string line)          // 'move' = the function takes ownership
{
    print(line);
}                                   // line freed here, at the end of the function

Declaration order doesn't matter, functions can call each other, mutual recursion works. return; in a void function is fine. Falling off the end returns a default value.

Lists

var fruits = list<string> { "apple", "banana" };
var temps = list<int>();            // empty, typed

fruits.Add("cherry");               // copies the value in
fruits.Remove("banana");            // removes first match, frees it
print(fruits.Count);                // 2
print(fruits[0]);                   // apple
fruits[0] = "pear";                 // frees apple, stores pear
foreach (var f in fruits) { print(f); }

Indexing is bounds-checked at runtime, a bad index routes to catch (below), it never reads random memory. Lists own their elements: whatever goes in is copied, whatever comes out (remove, clear, overwrite) is freed. float and bool lists aren't in yet.

Strings

var s = "hello";
print(len(s));                      // 5
var t = s + " world";               // new buffer, s untouched
var same = copy(s);                 // explicit duplicate
print($"x is {x}, and {x * 2}");    // any expression inside { }

print(contains(s, "ell"));          // true
print(startsWith(s, "he"));         // true
print(indexOf(s, "llo"));           // 2, or -1 when absent
print(sub(s, 1, 3));                // "ell"
print(parseInt("42") + 1);          // 43

Memory: one owner at a time

This is the part that makes H# different. int, float, bool copy by value. string and list have exactly one owner:

var s = "hello";
var t = s;              // moves: t owns the buffer now, s is retired
print(t);               // fine
// print(s);            // compile error: use of moved value 's'

var both = copy(s);     // want two? say so. s stays alive.

Why: if both stayed valid, one buffer would eventually be freed twice. Instead, ownership transfers at the move and the value is freed at its owner's last use, not at scope end, right after the final line that touches it. You can watch it:

var s = "abc";
print(len(s));          // buffer freed right after this line
print(mem());           // 0, mem() reports live heap allocations

The rules in short:

  • assignment moves, copy() duplicates, use-after-move is a compile error
  • moving a variable into a loop body is rejected (it would be freed on iteration one)
  • plain function parameters are borrowed: read, print, copy and pass them on, but you can't return them or hand them to a move parameter
  • move parameters and return values transfer ownership
  • a well-formed program ends with mem() == 0, threads included (the counter is atomic)

Tasks and lambdas

var payload = "data";
var t = Task.Run(() =>
{
    // runs on the thread pool; payload moved in, plain numbers copied
    print(payload);
    return 40 + 2;
});

_ = Task.Run(() => { print("fire and forget"); });   // discard form

var answer = await t;     // 42

Lambdas use () => (or (type name, ...) => when parameters are allowed) and are allowed wherever a task is expected. Capture rules follow the ownership model:

  • string/list captures move the value: after the capture the outer name is gone
  • int/float/bool capture by copy
  • borrowed values can't be captured, copy() them first
  • a task can't assign to the caller's variables

await blocks until the task finishes and hands over its owned result. Inside a lambda you have the whole language: loops, lists, files, even other tasks.

Networking

var ln = Tcp.Listen(8080);            // TCP server
var c = ln.Accept();                  // waits for a connection
var line = c.Recv();                  // reads one line (newline-framed)
c.Send($"echo: {line}\n");
c.Close();

var sock = Tcp.Connect("host", 8080); // TCP client, same Send/Recv/Close
var udp = Udp.Open();                 // UDP
udp.SendTo("127.0.0.1", 9000, "ping");
var msg = udp.Recv();

Failed network calls (bad host, closed socket, timeouts) route to catch like any other runtime error. Combine with tasks for a concurrent server: accept in a Task.Run, handle the connection inside. There's no HTTP builtin yet, but rt/demo-http.hs in the repo is a complete HTTP/1.1 server and client written in plain H# using exactly the string functions above, use it as a template.

Errors: try / catch

Runtime failures jump to the nearest catch at the statement boundary. No exception object, just recovery. Covered today: missing files, out-of-bounds indexes, division by zero, failed network calls:

try
{
    var data = read("config.txt");
    var x = 10 / n;
}
catch
{
    print("something failed, using defaults");
}

One gap to know: an error inside a task does not propagate to the caller yet, the task just returns a default value.

Builtins

call does
print(v) print any value with a newline
input(prompt) read one line from the user, returns string
len(s) / len(list) length
copy(s) duplicate a string
contains(s, sub) substring test, bool
startsWith(s, prefix) prefix test, bool
indexOf(s, sub) first position, or -1
sub(s, start, len) substring as a new string
parseInt(s) string to int
read(path) whole file as a string (routes to catch if missing)
write(path, content) write file to disk
exists(path) true if the file exists
delete(path) remove a file
mem() live heap allocation count, should be 0 at exit

Compiler flags

hsc program.hs                        # native build for the OS you're on
hsc program.hs -o myname              # pick the output name
hsc program.hs -platform linux64      # cross-compile: win64, linux64, osx64,
                                      # linux-arm64, osx-arm64

Every build runs LLVM's O2 optimization pipeline automatically. Extra flags pass through to clang, e.g. hsc app.hs -platform linux64 --sysroot=C:\sys\linux64 (cross-linking needs the target's C library, codegen works from anywhere). Errors come with positions: app.hs(14,7): error: use of moved value 's'.

Not in yet (so you don't go looking)

Cooperative async (await frees the thread instead of blocking; planned next), HTTPS/TLS, HTTP as builtins), float/bool lists, list deep-copy, multi-file programs. Set HS_DUMP_IR=1 to dump the generated IR on every build while debugging.