I love C#. It's my favorite language to program in. But I'd like to have some features from other languages built in from the ground up. I'm thinking about ownership, immutability, nullability, and functional programming.
Zurfur is a programming language I'm designing for fun and enlightenment. The language is named after our cat, Zurfur, who was named by my son. It's spelled ZurFUR because our cat has fur. The syntax is still being developed and nothing is set in stone. If you want to try it, click here https://gosub.com/zurfur
Ducumentation:
- Ownership Model which explains how ownership, mutability,and the type system work together in Zurfur.
- Memory Model which explains how the first version of Zurfur manages memory.
I'm also working on Zurfur Gui, which you can see here https://gosub.com/zurfurgui.
Older documentation on Confluence
Zurfur takes its main inspiration from C#, but borrows syntax and design concepts from Lobster, Zig, Midori, Golang, Rust, Python, JavaScript, and other languages.
- Prime directives:
- Fun and easy to use.
- Faster than C# and unsafe code just as fast as C.
- Target WebAssembly in the browser with easy JavaScript interop.
- Ownership, mutability, and nullability are part of the type system:
romeans read-only all the way down, not like C# wherereadonlyprotects only the top level.- All types are values (i.e. owned) except for
rotypes (e.g.Str), pointers (^T) and borrowed references (&T). - All mutable types have a
rocounterpart which can be copied quickly via single pointer assignment (e.g.Strandro List<Byte>). - Function parameters must be explicitly marked
mutif they modify anything. - References and pointers are non-nullable, but may use
?Tfor nullable.
- Fast and efficient:
- Return references and span used everywhere.
[]IntisSpan<Int>. - Functions pass parameters by reference, but will pass a copy when it is more efficient.
- Explicit
copyrequired when copying an object that requires dynamic allocation.
- Return references and span used everywhere.
Types fall into the basic categories of struct, data, object, and interface. Data types can
be subdivided into enum and flags. Finally, there are type attributes, such as ro, box, and
ref which can be applied to types to change their behavior. For a deeper understanding of these types,
review the Ownership Model
| Type | Description |
|---|---|
| struct | type struct can be trivially copied, bit for bit, and may not contain heap allocated data structures of any kind, including data, object, or interface types. |
| data | type data may hold mutable or immutable data. It should act like data, must be copyable, and the compiler will generate a ro counterpart which should have proper semantics. Private data is generally not used, except for caching. It may not contain an object or interface type. |
| object | type object is expected to contain mutable hidden data. It does not have a ro counterpart, does not need to be copyable nor have an == operator. An object type is stored in-line in memory and obeys the ownership rules as data types (i.e. it's not a reference type like in C#). |
| enum | type enum is a discriminated union, similar to Rust's enum. |
| flags | type flags is similar to C#'s enum. Flags take a numeric type parameter like type flags<Byte> or type flags<Int> to hold typed constants. |
| interface | type interface is a set of functions that can be implemented by any type. It is similar to Golang's interfaces (i.e. duck typed), but with support for default implementations. |
| ro | A ro type means it's read only. This can be used at the type declaration type ro data MyType or at the variable declaration ro List<Int>. |
| box | A box stores its data stored on the heap. It can be used at the type declaration type box data MyType or at the variable declaration List<box MyType>. |
| ref | A ref type is the only type that can contains a reference. It is restricted to being owned by the stack. |
The ones we all know and love:
nil, Bool, I8, Byte, I16, U16, I32, I32, Int, U64, F32, Float, Str
Int and Float are 64 bits wide.
| Type | Description |
|---|---|
| List<T> | Resizable mutable list of elements. ro List\<T\> is the immutable counterpart. |
| Span<T> | A view into a List. It has a constant length. Mutability of elements depends on usage (e.g Span from ro List is immutable, Span from List is mutable) |
| Map<K,V> | Unordered mutable map. ro Map<K,V> is the immutable counterpart. |
| Maybe<T> | Identical to ?T. Always optimized for pointers and references. |
| Result<T> | Same as !T. An optional containing either a return value or an Error interface. |
| Error | An interface containing a message string and an integer code |
| Str | Strings are an immutable list of bytes with support for UTF-8, similar to how Golang strings. |
All types have a compiler generated ro counterpart which can be copied very quickly since cloning
them is just copying a reference without dynamic allocation.
Interfaces use Golang style structural typing. For now, interface tables are created at compile time, based only on the functions defined in the same module as the type. This restriction might be removed in the future, allowing interfaces to be satisfied by functions defined at the call site.
Interface to interface conversion is a form of reflection, and will not be supported except via reflection. But, in that case, the result could be different than expected since the interface might have been implemented in code outside the module the type was defined in.
Interfaces will support default implementations. Interfaces will not support functions with generic parameters.
Modules and extension methods are used to organize code. Interfaces and lambdas are used for dynamic dispatch. Unions (i.e. sum types) are used for small well defined hierarchies. Type and interface embeddging can be used to "include" a base type in anther type using composition. Golang doesn’t need inheritance, and neither does Zurfur.
Zurfur uses significant whitespace for scope definition with strict, deterministic rules. Exactly four spaces per indentation level are required, with tabs forbidden. Curly braces are reserved for explicit scope boundaries (and interpolated strings) and provide an escape hatch when indent-independent syntax is needed, but the standard convention is to omit them and let indentation define scope naturally.
Line continuations are determined by a specific set of symbols, some at the end of the line, and some at the beginning of the line. Continued lines must use hanging indentation. This deterministic rule set, processed after lexical analysis but before parsing, eliminates ambiguity and makes the language easier for both humans and AI systems to generate and understand.
This design enforces visual consistency, reduces syntactic noise, and provides clear error messages. The strict rules make code more predictable for AI-assisted programming while the brace escape hatch maintains flexibility for edge cases like programmatically generated code or copy-pasted snippets.
The return keyword always exits the function declaration it appears in, regardless of nesting depth.
Zurfur requires an explicit return statement at the end of every function that returns a value.
Explicit return statements make the returned value immediately obvious without requiring knowledge
of expression-vs-statement rules. Functions that don't return a value can omit return entirely.
Within lambda expressions, the yield keyword is used to exit the lambda early and return control
to the calling code. Using yield instead of return in lambdas eliminates ambiguity when reading
nested code, as it clearly indicates that the control flow is returning to the caller of the lambda
rather than exiting an outer function.
Zurfur does not use an await keyword. Async functions (afun) are called with the same syntax as sync
functions (fun) and automatically suspend until completion. The compiler prevents sync functions from
calling async functions, maintaining clear boundaries in the type system.
Editor tooling should visually distinguish async call sites (e.g., underlining or color-coding) to help
developers understand control flow. This eliminates the syntactic overhead of await keywords while
preserving the semantic clarity of structured concurrency, similar to Go's approach but with explicit
async/sync separation at the function signature level.
While the default blocking behavior is appropriate for sequential logic, Zurfur provides the astart
keyword for launching async functions concurrently without blocking. The astart keyword starts an async
function in the background and immediately returns a Task<T>.
Zurfur is single-threaded like Node.js, using async/await for I/O concurrency without data races. For true
parallelism, Zurfur will support Web Workers with message-passing communication. Only owned, serializable
data can cross worker boundaries — borrowed references and ref types cannot escape the owning worker's
context, which the type system enforces. This provides a clean separation: async for concurrency within a
worker, workers for parallelism across cores. A built-in library will simplify spawning workers and marshaling
function calls across them automatically.
Local variable binding uses let for immutable, mut for mutable (but not assignable), var for
assignable (but not mutable), and var mut for assignable and mutable. For example:
// NOTE: getList returns List<Int>
let a = getList // `a` is un-assignable, the list is immutable
mut b = getList // `b` is un-assignable, the list is mutable
var c = getList // `c` is assignable, the list is immutable
var mut d = getList // `d` is assignable and the list is mutable
// NOTE: getRoList returns ro List<Int>
// `mut` and `var mut` are illegal because the list is immutable
let a = getList // `a` is un-assignable, the list is immutable
var c = getList // `c` is assignable, the list is immutable
// NOTE: getStruct returns some struct type
// `mut` and `var mut` are illegal because a struct is immutable
let a = getStruct // `a` is un-assignable, the struct cannot be modified
var c = getStruct // `c` is assignable, the struct can be modified
At the module level, functions, methods, and types are private to that module and it's children unless
the [pub] qualifier is specified.
Fields are public by default but can be made private by prefixing them with an _ underscore.
Private fields can have public getters and setters. The scope of a private variable is the file
that it is declared in.
[pub] // Make this type public
type Example
list1 List<Int> = [1,2,3] // Public, initialized with [1,2,3]
_list2 List<Int> // Private, initialized with []
_list3 List<Int> pub let // Private, but with public read-only access
_list4 List<Int> pub let mut // Private, but with public modify, but not assignable
The public getter or setter has the same name as the private field, except without the leading _.
Strings (i.e. Str) are immutable byte lists (i.e. ro List<Byte>), generally assumed to hold UTF
8 encoded characters. However, there is no rule enforcing the UTF8 encoding so they may hold any
binary data.
String literals start with a quote " (single line) or with """ (multi-line), and can be translated
at runtime using tr"string" syntax. They are interpolated with curly braces (e.g "{expression}").
Control characters may be put inside an interpolation (e.g. "{\t}" is a tab).
There is no StringBuilder type, use List<Byte> instead:
let sb = mut List<Byte>()
sb.push("Count from 1 to 10: ")
for count in 1..+10
sb.push(" {count}")
return sb.toStr()
Span is a view into a List, ro List, or Str, etc.. They are type ref and may never be stored
on the heap. Unlike in C#, a span can be used to pass data to an async function.
The declaration syntax []Type translates to Span<Type>. The following definitions are identical:
// The following definitions are identical:
fun writeData(data Span<Byte>) !Int
fun writeData(data []Byte) !Int
Mutating the len or capacity of a List (not the elements of it) while there is a Span or
reference pointing into it is a programming error, and fails the same as indexing outside of array bounds.
let list = mut List<Byte>()
list.push("Hello Pat") // list is "Hello Pat"
let slice = mut list[6..+3] // slice is "Pat"
slice[0] = "M"[0] // slice is "Mat", list is "Hello Mat"
list.push("!") // Runtime failure with stack trace in log file
TBD: Consider how to break out of the lambda. Use a return type of Breakable?
Operator precedence is mostly from Golang, but more compatible with C and gives an error where not compatible:
| Operators | Notes |
|---|---|
x.y f<type>(x) x.(type) a[i] |
Primary. |
- ~ & ref not sizeof typeof unsafe |
Unary. The ~ operator is both xor and unary |
complement, same as ^ in Golang. |
|
| @ | Capture the result of an expression into a variable. |
| ? | Use default for Maybe |
| ! | For Result and Maybe, generate value or return an error. This operator passes an error up |
to the caller when a Result has an Error. For example while stream.read(buffer)!@length != 0 |
|
passes an error up to the caller, or captures the value returned by read into the new variable length. |
|
| !!! | For Result and Maybe, generate value or panic. |
is is not as |
Type conversion and comparison |
| << >> | Bitwise shift (can't mix arithmetic and bit operators, TBD: always require parentheses) |
| * / % & | Multiply, divide, modulus, and bitwise AND (can't mix arithmetic and bit operators) |
| ~ | Bitwise XOR (can't mix with arithmetic operators) |
| + - | | Add, bitwise OR (can't mix arithmetic and bit operators) |
| .. ..+ | Range (Low..High) and range count (Low..+Count). Inclusive of low, exclusive of high. |
The range operator .. takes two Ints and makes a Range which is a type Range {high Int; low Int}. |
|
The ..+ operator also makes a range, but the second parameter is a count (high = low + count). |
|
== != < <= > >= in not in |
Operator == does not default to object comparison and only works |
when it is defined by the given type. Comparisons are not associative, so a == b == c is illegal. |
|
and |
Conditional and, short circuit |
or |
Conditional or, short circuit |
ife a : b : c |
If expression, *TBD: Syntax? |
| => | Lambda |
| key:value | Key value pair, only allowed inside (), [] or where expected. |
| , | The comma is a separator and not an expression. |
| = += -= *= /= %= &= | = ~= <<= >>= |
while (a = count) < 20 are illegal. In this case, while count@a < 20. |
+, -, *, /, %, and in are the only operators that may be individually overloaded. The
== and != operator may be overloaded together by implementing fun _opEq(a myType, b myType) bool.
All six comparison operators, ==, !=, <, <=, >=, and > can be implemented with just one
function: fun _opCmp(a myType, b myType) Int. If both comparison functions are defined, _opEq is
used for equality comparisons and _opCmp is used for the others. TBD: _opCmpOrdered vs _opCmp
for unordered?
Like Golang, semicolons are required between statements but are inserted automatically at the end of lines based on the last non-comment token and the first token of the next line.
Unlike Golang and C#, compound statements (if, else, while, for, lambdas, etc.) can accept
multiple lines without needing braces. The indentation is checked to ensure it matches the expected
behavior.
- Indentation is four spaces per scope level. No tabs anywhere in the source code except within multi-line string literals.
- One statement per line, unless it's a continuation line. It's a continuation line if:
- The end of the previous line is
[,(,,, or=>. - The line begins with an operator, including
],),,,",and,or,in,+,.,=, etc.
- The end of the previous line is
- Compound statements (e.g.,
if,while,for, etc.) may use or omit curly braces, but the convention is to omit them.
The while loop is the same as C#. There is no do statement, but it is easy to make one using scope.
The scope statement creates a new scope:
scope
let file = File.open("My File")
doStuff(file)
// File variable is out of scope here
The scope statement can be turned into a loop using the continue statement:
scope
doSomething()
if weWantToRepeat()
continue
Likewise, break can be used to exit the scope early.
For the time being, for loops only allow one format: for newVariable in expression.
The simplest form of the for loop is when the expression evaluates to an integer:
// Print the numbers 0 to 9
for i in 10
Log.info("{i}")
// Print numbers from 1 to 10
for i in 1..+10
Log.info("{i}")
// Increment all the numbers in a list
for i in list.len
list[i] += 1
// Log key value pairs of all elements in a map
for kv in map
Log.info("Key: {kv.key} is {kv.value}")
When iterating over a collection, just like in C#, it is illegal to add or remove elements from the collection.
Both switch and match are reserved for future use. For now, use if,
elif, and else to simulate them:
if myNum < 1
doStuff()
doOtherStuff()
elif myNum in 1..3
doMoreStuff()
else myNum >= 3
doTheLastThing()