JoyC transpiles to C99. It keeps what makes C great while addressing some of the boilerplate and cross-platform friction that can slow you down. The sections below walk through each feature.
#include "joyc/mem.jc"
#include "joyc/io.jc"
struct Point { i32 x; i32 y }
enum Direction { North, South, East, West }
void move(Point *p, Direction dir, i32 dist) {
switch dir {
case Direction.North: p.y += dist // auto-deref
case Direction.South: p.y -= dist
case Direction.East: p.x += dist
case Direction.West: p.x -= dist
}
}
i32 sum(i32[] items) {
i32 total = 0
for usize i = 0; i < items.len; i++: total += items.ptr[i]
return total
}
i32 main() {
Point p = { .x = 0, .y = 0 }
move(&p, Direction.North, 10)
i32[5] nums = {1, 2, 3, 4, 5}
io.println("sum=%d, pos=(%d,%d)", sum(nums[:]), p.x, p.y)
char *buf = mem.alloc(256)
defer mem.dealloc(buf)
return 0
} In standard C, using fixed-width types like int32_t requires including <stdint.h>, and bool requires <stdbool.h>. JoyC has these types built in, so you can use them directly.
| C Type | JoyC Type |
|---|---|
int8_t | i8 |
uint8_t | u8 |
int16_t | i16 |
uint16_t | u16 |
int32_t | i32 |
uint32_t | u32 |
int64_t | i64 |
uint64_t | u64 |
| C Type | JoyC Type |
|---|---|
intptr_t | isize |
size_t | usize |
float | f32 |
double | f64 |
_Bool | bool |
uint32_t | rune |
(void*)0 | null |
void, char | same |
i32 x = 42
f64 pi = 3.14159
bool done = false
rune codepoint = 0x1F600 // Unicode: U+1F600
char* name = null JoyC makes a handful of syntax changes compared to C. None of them change the semantics of your code, but they tend to reduce the amount of punctuation you need to write.
JoyC inserts semicolons automatically using the same rules as Go. If a line ends with an operator, the insertion is suppressed so that multi-line expressions work as expected.
i32 x = 42
i32 y = x + 10
i32 total = first_value +
second_value +
third_value
Control flow statements like if, while, for, and switch do not require parentheses around their conditions. You can still use them if you prefer, but they are optional.
if x > 0 {
printf("positive\n")
}
while count > 0 {
count--
}
for i32 i = 0; i < 10; i++ {
printf("%d\n", i)
}
When a block body is just a single statement, you can use a colon instead of braces. This is especially useful for short conditionals and loops.
if x > 0: return x
while n > 1: n /= 2
for i32 i = 0; i < 10; i++: printf("%d ", i)
and / or keywordsThe keywords and and or are available as alternatives to && and ||. Both forms work interchangeably, so you can mix them depending on what reads best.
if x > 0 and x < 100 {
printf("in range\n")
}
if done or cancelled: return
You can call functions before they are defined in the file. The compiler resolves declaration order automatically, so prototypes and forward declarations are not needed.
i32 main() {
greet("world") // works, no prototype needed
}
void greet(const char *name) {
printf("Hello, %s!\n", name)
} In C, using a struct without the struct keyword at every usage site requires a typedef, and definitions need trailing semicolons. JoyC removes both of those requirements.
struct Point {
i32 x
i32 y
}
Point p = { .x = 10, .y = 20 } // no "struct Point"
Point zero = {} // all fields zero-initialized
In C, all enum values share the same namespace, so two enums can’t both have a member named Red. JoyC scopes enum values to their type, which avoids these collisions.
enum Color { Red, Green, Blue }
enum Light { Red, Yellow, Green } // no conflict
Color c = Color.Red
Light l = Light.Green
In C, accessing a struct field through a pointer requires the -> operator. JoyC lets you use . for both values and pointers, and the compiler emits the correct operator in the generated C.
void move(Point *p, i32 dx, i32 dy) {
p.x += dx // automatically becomes p->x in C
p.y += dy
}
Empty braces {} zero-initialize any struct or array. If you provide only some fields in a designated initializer, the rest are guaranteed to be zero.
Point p = {} // x=0, y=0
i32[10] arr = {} // all elements zero
Point p = { .x = 10 } // y is guaranteed zero JoyC moves array dimensions from the variable name into the type, so the type reads left to right. It also introduces slices, which are fat pointers that pair a pointer with a length.
In C, array dimensions are attached to the variable name (int numbers[10]), which can get confusing in complex declarations. JoyC puts the dimensions in the type instead, so the type and variable name are always clearly separated.
int numbers[10];i32[10] numbersA slice T[] is a fat pointer that pairs a data pointer with a length. Under the hood it compiles to a simple anonymous struct ({T* ptr, usize len}) with no runtime overhead.
i32[5] arr = {1, 2, 3, 4, 5}
i32[] s = arr[:] // slice of whole array
i32[] sub = s[1:3] // sub-slice: elements 1, 2
i32[] rest = s[2:] // from index 2 to end
i32[] first = s[:3] // first 3 elements
usize len = s.len // length
i32* ptr = s.ptr // raw pointer access
Slices are commonly used as function parameters because they let you accept arrays of any size without needing a separate length argument. The length travels with the pointer.
i32 sum(i32[] items) {
i32 total = 0
for usize i = 0; i < items.len; i++ {
total += items.ptr[i]
}
return total
}
i32[5] data = {10, 20, 30, 40, 50}
i32 result = sum(data[:]) // pass slice of array The defer statement lets you schedule cleanup code that runs when the current scope exits. The compiler inlines the deferred expression at every exit point in the scope, so there is no runtime overhead.
void process() {
FILE *f = fopen("data.txt", "r")
defer fclose(f)
char *buf = malloc(1024)
defer free(buf)
if error: return // both fclose and free run here
// ... and here at end of scope
}
When multiple defer statements appear in the same scope, they execute in reverse order (last in, first out). This matches the natural pattern of nested resource acquisition.
defer printf("1st ")
defer printf("2nd ")
defer printf("3rd ")
// prints: 3rd 2nd 1st
Defer is not a runtime feature. The compiler copies the deferred expression to every exit point in the scope (return statements, end of function). The generated C has no overhead.
JoyC adds a few conveniences to function declarations and calls that C doesn’t have, including default parameter values, named arguments at call sites, and aliasing for external C symbols.
You can give function parameters default values. When a caller omits those arguments, the compiler fills in the defaults at the call site.
void draw(i32 x, i32 y, f32 scale = 1.0, bool visible = true)
draw(10, 20) // scale=1.0, visible=true
draw(10, 20, 2.0) // scale=2.0, visible=true
Function calls can use named arguments to make the intent clearer at the call site. Named arguments can also be passed in any order, and the compiler will reorder them to match the function signature.
create_window(width: 800, height: 600, title: "My App")
i32 add(i32 a, i32 b) { return a + b }
add(b: 10, a: 5) // reordered to add(5, 10) in generated C
The extern declaration can include a string literal that specifies the actual C symbol name. This lets you give external functions more readable names in your JoyC code while still linking to the original symbols.
extern "malloc" void *alloc(usize size)
extern "free" void dealloc(void *ptr)
extern "strlen" usize str_len(const char *s)
void *p = alloc(1024) // calls malloc(1024)
dealloc(p) // calls free(p) JoyC makes two changes to control flow compared to C. Switch cases no longer fall through by default, and loops can be labeled so you can break or continue an outer loop from within a nested one.
breakIn C, forgetting a break at the end of a switch case silently falls through to the next one. JoyC flips this so that cases do not fall through by default. When you do want fallthrough, you say so explicitly with the fallthrough keyword.
switch day {
case 1:
printf("Monday\n")
case 6:
printf("Saturday — also a ")
fallthrough
case 7:
printf("weekend day\n")
default:
printf("some day\n")
}
Loops can be given a label by prefixing them with :name. You can then use that label with break or continue to target an outer loop from within a nested one.
:outer for i32 i = 0; i < 10; i++ {
for i32 j = 0; j < 10; j++ {
if i + j == 15: break :outer
if j == 5: continue :outer
}
} Since C has a flat global symbol namespace, libraries typically prefix their functions manually (e.g., SDL_CreateWindow). JoyC’s #namespace directive handles this for you by automatically prefixing all symbols in a file.
// math.jc
#namespace math
const f64 PI = 3.14159
f64 square(f64 x) { return x * x }
Other files can use these symbols with the namespace qualifier, in this case, math.PI and math.square(). In the generated C code, these will be prefixed with math_ to avoid naming conflicts (e.g., math_PI and math_square).
By default, the namespace name is used as the C prefix (e.g., math produces math_square). You can override this with an explicit string literal if the generated prefix needs to match an existing convention.
#namespace os "OS_" // os.args() → OS_args() in C
You can include other .jc files directly, and they will be parsed and inlined by the compiler. Standard C headers are passed through unchanged into the generated C output.
#include "mathlib.jc" // JoyC file: parsed and inlined
#include <stdio.h> // C header: passed through In an effort to avoid external build systems like Makefiles, CMake, Bazel, and so on, JoyC uses in-source directives to handle linking, build configuration, code generation, and conditional compilation.
#link: Link libraries| Directive | Purpose | C Equivalent |
|---|---|---|
#link "m" | Link a shared library | -lm |
#link:static "z" | Link a static library | -static -lz |
#link:framework "Cocoa" | Link a macOS framework | -framework Cocoa |
#link:system "pthread" | Link a system library | -lpthread |
#link:flag "-Wl,-rpath,/opt" | Raw linker flag | passed through |
#config: Build configuration| Directive | Purpose | C Equivalent |
|---|---|---|
#config:libpath "/usr/local/lib" | Library search path | -L/usr/local/lib |
#config:include "/usr/local/include" | Header search path | -I/usr/local/include |
#config:pkg "openssl" | Use pkg-config | $(pkg-config ...) |
#config:cflags "-Wall -O2" | C compiler flags | -Wall -O2 |
#macro: Code generationThe #macro directive defines multi-line macros that support token pasting with #(param). This is useful for generating repetitive struct or function definitions from a template.
#macro PAIR(T, Name) {
struct #(Name) { T first; T second; }
Name #(Name)_make(T a, T b) {
Name p; p.first = a; p.second = b; return p
}
}
PAIR(int, IntPair)
IntPair p = IntPair_make(1, 2)
Rather than checking compiler-specific macros like _WIN32, __APPLE__, or __linux__ (which vary between compilers and can be easy to get wrong), JoyC provides a consistent set of built-in defines.
| Category | Defines |
|---|---|
| Platform | IS_MACOS, IS_LINUX, IS_WINDOWS, IS_FREEBSD, IS_UNIX |
| Architecture | IS_X86_64, IS_ARM64, IS_WASM |
| Pointer size | IS_64BIT, IS_32BIT |
| Build mode | IS_DEBUG, IS_RELEASE, IS_SANITIZE, IS_SMALL |
#if IS_MACOS {
printf("Running on macOS\n")
}
#if IS_LINUX {
printf("Running on Linux\n")
}
#embed: File embedding#embed includes the contents of a file as a string literal at compile time. This is useful for embedding shaders, SQL queries, or other resources directly into your binary.
const char* vertex_shader = #embed "shaders/vertex.glsl"
const char* query = #embed "sql/init.sql"
#assert checks a condition at compile time and produces an error if it fails.
#assert sizeof(i32) == 4
#assert sizeof(void*) == 8
#todo marks unfinished code. In debug builds it produces a warning; in release builds it becomes a compile error, so you don’t accidentally ship incomplete work.
#unreachable tells the compiler that a code path should never be reached. In debug builds it triggers a trap; in release builds it becomes an optimizer hint.
#deprecated causes a warning whenever a marked symbol is used, which is helpful when migrating callers away from old APIs.
#deprecated "use new_api() instead"
void old_api() { }
void process() {
#todo "handle the error case"
// ...
#unreachable // control should never reach here
}
#packed removes padding between struct fields, and offsetof(T, field) returns the byte offset of a field within a struct.
#packed
struct Header {
u8 type
u32 length
}
// sizeof(Header) == 5, not 8
usize off = offsetof(Header, length) // 1
#export marks a function or variable for export when building a shared library. The compiler emits the correct platform-specific attribute (__declspec(dllexport) on Windows, __attribute__((visibility("default"))) on Unix) so you don’t need to manage that yourself.
threadlocal puts a variable in thread-local storage so each thread gets its own copy. Different C compilers spell this differently (_Thread_local in C11, __thread on GCC/Clang, __declspec(thread) on MSVC), but JoyC handles the discrepancy for you.
#export
void public_api() { }
threadlocal i32 per_thread_counter = 0
typeof(expr) evaluates to the type of any expression, which is useful in macros and generic-style code.
i32 x = 42
typeof(x) y = x + 1 // y is i32 Rather than requiring a separate test framework or test files, JoyC lets you write tests and benchmarks alongside the code they exercise using #test and #benchmark blocks.
#include "joyc/assert.jc"
i32 add(i32 a, i32 b) { return a + b }
#test "addition works" {
assert(add(2, 3) == 5)
assert(add(-1, 1) == 0)
}
#test "handles overflow" {
assert(add(2147483647, 0) == 2147483647)
}
#benchmark "add performance" {
volatile i32 x = add(1, 2)
}
joyc test myfile.jc # run all #test blocks
joyc bench myfile.jc # run all #benchmark blocks
The compiler produces Elm-style diagnostics with source locations and explanations. It currently checks for the following categories of issues.
return, break, continue, gotobreak/continue outside loopsfallthrough outside switchi64 → i32)The joyc command line tool is the single entry point for compiling, testing, formatting, translating, and inspecting your code.
| Command | Description |
|---|---|
joyc build <file.jc> | Compile to binary |
joyc run <file.jc> | Compile and run |
joyc test <file.jc> | Run #test blocks |
joyc bench <file.jc> | Run #benchmark blocks |
joyc fmt [-w] <file.jc> | Format source (-w for in-place) |
joyc emit-c <file.jc> | Emit generated C99 source |
joyc translate <file.c> | Convert C source to JoyC |
joyc tokens <file.jc> | Dump lexer token stream |
joyc ast <file.jc> | Dump the AST |
joyc vet <file.jc> | Check for style issues |
joyc doc <file.jc> | Generate documentation |
joyc fetch | Download dependencies from joyc.mod |
joyc watch [--run] <file.jc> | Rebuild on file changes |
joyc init | Create a new project |
joyc clean | Remove .joyc/ build directory |
joyc version | Show version |
joyc build -o myapp main.jc # set output path
joyc build --debug main.jc # debug profile
joyc build --release main.jc # release profile
joyc build --profile sanitize main.jc # ASan + UBSan
joyc build --profile small main.jc # optimize for size
joyc build --cc clang main.jc # choose C compiler
joyc build --target linux-x86_64 main.jc # cross-compile
| Profile | Optimization | Debug Info | Bounds Checks | Sanitizers | Define |
|---|---|---|---|---|---|
debug | -O0 | Yes | Yes | No | IS_DEBUG |
release | -O2 | No | No | No | IS_RELEASE |
sanitize | -O1 | Yes | Yes | ASan + UBSan | IS_SANITIZE |
small | -Os | No | No | No | IS_SMALL |
joyc build --target linux-x86_64 main.jc
joyc build --target macos-arm64 main.jc
joyc build --target windows-x86_64 --cc x86_64-w64-mingw32-gcc main.jc
The supported target platforms are linux, macos (or darwin), windows (or win32), freebsd, and wasm. The supported architectures are x86_64 (or amd64), arm64 (or aarch64), and wasm32.
You can convert existing C code to JoyC using the translate command.
joyc translate input.c -o output.jc
joyc translate header.h source.c -o combined.jc
joyc translate --strip-prefix SDL_ --namespace sdl sdl.h -o sdl.jc
joyc translate --rules sdl.rules sdl.h -o sdl.jc
The translator handles common conversions automatically: int32_t becomes i32, semicolons are removed, -> becomes ., NULL becomes null, struct prefixes are dropped, header guards are stripped, and redundant typedefs are cleaned up. For more complex renaming like per-kind case transforms, explicit renames, or ignore patterns, you can provide a --rules file.
The vscode/ directory contains a VS Code extension that provides syntax highlighting through a TextMate grammar. It also includes a full language server with support for hover information, completions, go-to-definition, find references, rename, formatting, semantic tokens, document symbols, and signature help. Code snippets are included as well.
To see how these features come together in practice, here is a small cross-platform HTTP server. It pulls in the standard library socket module, links against platform-specific libraries with conditional compilation, serves an embedded HTML file, and includes inline tests for the routing logic.
// server.jc
#include "joyc/assert.jc"
#include "joyc/io.jc"
#include "joyc/mem.jc"
#include "joyc/strings.jc"
#include "joyc/socket.jc"
#include <string.h>
#link "m"
#if IS_LINUX {
#link "pthread"
}
#if IS_MACOS {
#link:framework "CoreFoundation"
}
#if IS_WINDOWS {
#link "ws2_32"
}
#namespace server
const char* INDEX_PAGE = #embed "static/index.html"
enum Status { Ok = 200, NotFound = 404, Error = 500 }
struct Response {
Status status
const char* body
usize body_len
}
struct Server {
i32 sock
u16 port
bool running
}
Response route(const char* path) {
if strings.equals(path, "/") or strings.equals(path, "/index.html") {
return (Response){
.status = Status.Ok,
.body = INDEX_PAGE,
.body_len = strings.len(INDEX_PAGE)
}
}
return (Response){
.status = Status.NotFound,
.body = "not found",
.body_len = 9
}
}
void handle(i32 conn) {
defer socket.close(conn)
char[4096] buf = {}
isize n = socket.receive(conn, buf[:])
if n <= 0: return
Response res = route("/")
char[512] out = {}
i32 len = io.sprint(out, 512,
"HTTP/1.1 %d OK\r\nContent-Length: %zu\r\n\r\n%s",
(i32)res.status, res.body_len, res.body)
socket.send(conn, out[:len])
}
bool start(Server *s, u16 port = 8080) {
s.sock = socket.tcp() // auto-deref
if s.sock < 0: return false
if socket.bind(s.sock, port) < 0: return false
if socket.listen(s.sock, backlog: 128) < 0: return false
s.port = port
s.running = true
return true
}
void stop(Server *s) {
s.running = false
socket.close(s.sock)
}
i32 main() {
Server srv = {}
if !start(&srv, port: 3000) {
io.eprintln("failed to start server")
return 1
}
defer stop(&srv)
io.println("listening on :%d", srv.port)
while srv.running {
i32 conn = socket.accept(srv.sock)
if conn >= 0: handle(conn)
}
}
#test "root returns 200" {
Response res = route("/")
assert(res.status == Status.Ok and res.body_len > 0)
}
#test "unknown path returns 404" {
Response res = route("/nope")
assert(res.status == Status.NotFound)
} Instead of relying on external package managers or build system scripts, JoyC handles dependency management through a joyc.mod file in your project root.
joyc.modpackage myapp
version 0.1.0
require (
stb git https://github.com/nothings/stb v1.0
cjson git https://github.com/DaveGamble/cJSON v1.7.18
sokol git https://github.com/nickglenn/sokol-joyc branch main
)
system (
openssl pkg-config openssl >= 1.1
sdl2 pkg-config sdl2
)
joyc fetch # download deps, uses lock file if present
joyc fetch --update # update to latest matching versions
Dependencies are cloned into .joyc/vendor/ and pinned in joyc.lock for reproducible builds. Once fetched, you can include them directly in your source files.
#include "stb/stb_image.jc"
#include "cjson/cJSON.h"
System dependencies are verified via pkg-config.
If a dependency has its own joyc.mod, its requirements are resolved automatically. JoyC uses flat dependency resolution, meaning all packages must agree on a single version per dependency name.
joyc.modprofile release (
cc clang
flags -flto -march=native
)
profile wasm (
cc emcc
flags -sEXPORTED_FUNCTIONS=['_main']
)
These merge with built-in profile flags when --profile is used.
JoyC ships a small standard library that is embedded directly in the compiler binary. There is no runtime and no garbage collector. Each module is opt-in through #include, and every function compiles to straightforward C.
#include "joyc/io.jc"
#include "joyc/math.jc"
#include "joyc/mem.jc"
int main() {
f64 val = math.clamp(42.0, 0.0, 100.0)
void *buf = mem.alloc(1024)
defer mem.dealloc(buf)
io.println("clamped: %g", val)
return 0
}
| Module | Include | Purpose |
|---|---|---|
| io | joyc/io.jc | print, println, eprint, eprintln, sprint, scan |
| math | joyc/math.jc | clamp, lerp, min, max, abs (f32/f64/i32), PI, E, TAU |
| mem | joyc/mem.jc | alloc, zalloc, dealloc, resize, copy, zero, set |
| strings | joyc/strings.jc | starts_with, ends_with, contains, equals, len, index_of, find |
| hash | joyc/hash.jc | fnv1a (32/64-bit), crc32 |
| endian | joyc/endian.jc | bswap16/32/64, htobe16/32, be16toh, be32toh |
| assert | joyc/assert.jc | Runtime assert with error messages |
| fs | joyc/fs.jc | Cross-platform file I/O: open, close, read, read_line, read_all, write, write_string, writef, write_all, seek, tell, eof, flush |
| test | joyc/test.jc | assert_eq_i32, assert_eq_i64, assert_str_eq, assert_near_f64, assert_not_null |
| socket | joyc/socket.jc | tcp, udp, close, bind, listen, accept, connect, send, receive, send_to, receive_from, INVALID |
Every function is namespaced: math.clamp, mem.alloc, strings.contains, socket.tcp, etc.