Create Your Own Programming Language (Natrix Example)
Natrix is a tiny, strictly typed, interpreted language implemented in C. It’s built to be read, modified, and extended—so you can learn real language design without drowning in a huge codebase. The interpreter shows how tokenization, parsing, and runtime evaluation fit together, with just enough features to feel practical.
Why build an interpreted language in C at all?
- To demystify tooling you use every day. Once you’ve written a lexer and evaluator, compilers and VMs stop being black boxes.
- To sharpen systems skills: memory management, error handling, and data structures.
- To prototype ideas quickly: custom scripting, DSLs, or embeddable task engines.
- To own the trade-offs between readability, performance, and feature scope.
Natrix leans into clarity over cleverness. You’ll see strict types, simple control flow, and a small standard library. From there, you can layer in objects without classes, method-call sugar, and even PCRE2-powered regex—incrementally and intentionally. You can browse the Github repo source code to dig into the details and contribute.
Create a Programming Language
You don’t need a giant compiler course to start. A practical path:
- Define a small grammar first: numbers, strings, booleans, variables, functions,
if/else
, andwhile
. - Write a lexer to produce tokens.
- Use a Pratt parser (expression-first) to build an AST.
- Evaluate nodes directly in an interpreter with a scoped environment.
A minimal Natrix script looks like this:
// Types: str, num, bool. Functions have typed params and returns.
func Add(num a = 0, num b = 0) : num => { return a + b; }
num x = Add(2, 3);
print x; // 5
bool flag = true;
if (flag) { print "Hello, Natrix!"; } else { print "Nope"; }
This is enough to get a working system and a place to add features later (records, regex, and so on).
Create A Programming Language
When you build yours, decide early on:
- Typing model: Natrix uses strict types on variables and function signatures.
- Control flow: Curly-brace blocks,
if/else
,while
, andtry/catch
. - Standard library: Start tiny. Add printing, system info, and core math.
Example of control flow and error handling:
func Divide(num a, num b) : num => {
if (b == 0) { throw "division by zero"; }
return a / b;
}
try {
num v = Divide(10, 2);
print v; // 5
v = Divide(1, 0); // throws
print v; // never reached
} catch {
// Built-in: error_message, error_line (names may vary in your build)
print "Caught: " + error_message;
}
Create Your Own Programming Language
Here’s a pattern that keeps things simple while offering “OO-ish” ergonomics without real classes:
- Add
struct
for nominal data schemas. - Add
obj { ... }
as a dictionary literal. - Add method-call sugar:
a.B(c)
desugars toB(a, c)
whereB
is a normal function.
// Data schema (nominal)
struct Parrot { name: str; wants: str; age: num }
// A plain function that *looks* like a method when called with sugar
func Speak(Parrot p, str what) : void => {
print p.name + ": " + what;
}
Parrot polly = obj { name: "Polly", wants: "cracker", age: 2 };
// Method sugar: polly.Speak("hello") → Speak(polly, "hello")
polly.Speak("hello"); // Polly: hello
polly.age = 3;
print polly.age; // 3
This gives you data modeling, type checks on fields, and clean ergonomics. No classes, no inheritance, no giant runtime.
Interpreter Language
An interpreter executes your AST (or bytecode) directly. That means:
- Fast iteration: run code as soon as it parses.
- Clear errors: you can throw typed runtime errors with line info.
- Portability: ship a single binary for many platforms.
A tiny loop with state:
num i = 0;
while (i < 3) {
print "tick: " + i;
i = i + 1;
}
What Is Interpreted Language
It’s a language where the primary execution model is evaluate-as-you-go, not ahead-of-time compilation to a native binary. Interpreted systems often have:
- Dynamic evaluation of expressions.
- Well-defined runtime errors (e.g., division by zero).
- Foreign-function bridges to call into C libraries.
Natrix shows these traits but keeps types strict for clarity:
str platform_label = sys.platform;
str version_label = sys.version;
print platform_label; // e.g., "darwin"
print version_label; // e.g., "0.2.0"
Interpreted Languages Examples
Famous examples include Python, Ruby, and Lua. Each prioritizes speed of development. Natrix follows that spirit with stricter typings and a small core designed for learning:
func Greet(str name = "world") : void => {
print "Hello, " + name + "!";
}
Greet(); // default param
Greet("Natrix"); // explicit
Compiled Language Vs Interpreted Language
Both models are useful; they just optimize for different moments:
- Compiled (e.g., C, Rust): long build times, top performance, smaller runtime overhead.
- Interpreted (e.g., Natrix): instant feedback, simpler deploy, great for exploration and education.
You can mix approaches. Many interpreters add a bytecode layer or optional JIT later for hot paths. Start simple, then optimize what matters.
How Natrix Was Designed And Built
Natrix’s aim is clarity over cleverness. The implementation choices reflect that:
-
Strict Types, Simple Rules
- Variables and function params declare
str
,num
, orbool
. Reassignments must keep the same type. - Functions use clear signatures, optional defaults, and consistent naming: CapitalCase for functions, snake_case for variables.
- Variables and function params declare
-
Objects Without Classes
struct
provides nominal schemas for strict field checks.obj { ... }
creates dictionary values.- Method-call sugar (
a.B(c)
→B(a, c)
) delivers the ergonomics people expect, without inventing classes or vtables.
-
Practical Built-Ins
You can start with “tiny” print
, sys.platform
, and sys.version
calls, and then implement regex through a C library like PCRE2 for performance with minimal code. Idiomatic built-ins:
bool ok = RegexMatch("[A-Z]{3}[0-9]{2}", "ABC12 xyz"); // true
num i = RegexSearch("error: .*", "warn\nerror: oops"); // start index
str s = RegexReplace("(\\w+)-(\\d+)", "item-42", "<\\1 #\\2>");
print ok;
print i;
print s;
-
Readable C Interpreter
- A lexer that emits tokens.
- A Pratt parser for expressions and precedence.
- A small evaluator with a scoped environment and a consistent error path so
try/catch
can intercept failures. - Source here: https://github.com/basperheim/interpreted-language-example-clang
-
Quality Of Life
Use the const
(shallow) keyword for bindings you don’t want reassigned:
const Parrot p = obj { name: "Kiwi", wants: "seeds", age: 4 };
p.age = 5; // allowed (field mutate)
// p = obj { ... } // error: cannot reassign const binding
TL;DR
- Keep the grammar tiny.
- Prefer strict, readable rules over features.
- Add struct + obj + sugar for “methods” without classes.
- Use a proven C regex library; don’t write your own.
- Learn by reading and changing a small interpreter: Natrix Source Code
Build Tip (If You Add PCRE2)
Break long commands for readability:
clang -O2 -std=c17 -Wall -Wextra -o natrix \
src/lexer.c src/parser.c src/runtime.c src/main.c \
-lpcre2-8
That’s it. You’ll have a compact, teachable system that’s fun to extend and easy to reason about.
Conclusion
You don’t need a massive compiler stack to learn language design. With Natrix you get a compact interpreter that favors strict, readable rules: typed variables and functions, clean control flow, and objects without classes via struct
, obj { ... }
, and call sugar. From there you can add practical power—like PCRE2 regex—without inflating the core.
If you’re curious where to go next:
- Add collections (arrays or maps) with strict element typing.
- Introduce a bytecode layer for easier optimization.
- Expose a small FFI to call C functions safely.
- Experiment with immutable bindings (
const
) and deeper immutability rules.
Grab the code, run the examples, and try a feature or two of your own. Small, comprehensible systems are the best place to learn—and Natrix is designed exactly for that.