Getting started
Tinoc is a systems programming language that compiles to C99. Write .tnc source, emit portable C, and build it with any C99 toolchain.
The compiler is under active development, so there is nothing to install yet. These docs describe the language as it is being built and track the repository.
Hello, world
main.tnc
#import std.io;
// Main function
fn main() void {
var name str = "Prathmesh";
const lang = "Tinoc";
io.println("{s} is creator of {s} Programming Language!", name, lang);
}
What the compiler emits
main.tnc compiles to readable C99 with a small runtime header. str becomes a plain struct, and io.println becomes printf.
main.c — emitted
#include <stdio.h>
#include <tinoc.h>
int main() {
str name = {"Prathmesh", 9};
const str lang = {"Tinoc", 5};
printf("%s is creator of %s Programming Language!\n", name.data, lang.data);
}
The pipeline is tinoc build main.tnc, and main.c goes to your C compiler. There is no runtime, VM, or garbage collector.
Variables
A variable can be mutated.
Syntax
var <identifier> <type>; // Decl only, with explicit type.
var <identifier> <type> = <expression>; // Decl + init, explicit type.
var <identifier> = <expression>; // Decl + init, inferred.
Example
main.tnc
fn main() void {
var name str = "Lucifer";
name = "Julius";
var number = 101; // inferred as i32
number += 10;
var isAdmin = false; // inferred as bool
isAdmin = true;
}
Constants
A constant cannot be mutated.
Syntax
const <identifier> <type>; // Decl only, with explicit type.
const <identifier> <type> = <expression>; // Decl + init, explicit type.
const <identifier> = <expression>; // Decl + init, inferred.
Example
main.tnc
fn main() void {
const lang str = "Tinoc";
const codename = "C^";
// lang = "Tinoc Is Not C"
// Won't work — compile error.
}
Integers
Integer literals
literals.tnc
const decimal_int = 98222;
const hex_int = 0xff;
const another_hex_int = 0xFF;
const octal_int = 0o755;
const binary_int = 0b11110000;
// underscores may sit between two digits as a visual separator
const one_billion = 1_000_000_000;
const binary_mask = 0b1_1111_1111;
const permissions = 0o7_5_5;
const big_address = 0xFF80_0000_0000_0000;
The compiler infers the width of each integer literal. It defaults to i32 and widens to i64 or i128 when the value requires it.
For values not known at compile time, add the type explicitly:
divide.tnc
fn divide(a i32, b i32) i32 {
return a / b;
}
Floats
Float literals
literals.tnc
const floating_point = 123.0E+77;
const another_float = 123.0;
const yet_another = 123.0e+77;
const hex_floating_point = 0x103.70p-5;
const another_hex_float = 0x103.70;
const yet_another_hex_float = 0x103.70P-5;
// underscores may sit between two digits as a visual separator
const lightspeed = 299_792_458.000_000;
const nanosecond = 0.000_000_001;
const more_hex = 0x1234_5678.9ABC_CDEFp-10;
Tinoc has no syntax for NaN, infinity, or negative infinity. Use std.math for these values.
math.tnc
#import std.math;
const inf = math.inf();
const negative_inf = - math.inf();
const nan = math.nan();
Operators
Operators, their syntax, and their behavior:
| Name | Syntax | Notes |
| Addition | a + b · a += b | Integers & floats; can overflow |
| Wrapping Addition | a +% b · a +%= b | Wraps on overflow |
| Saturating Addition | a +| b · a +|= b | Clamps on overflow |
| Subtraction | a - b · a -= b | Integers & floats |
| Wrapping Subtraction | a -% b · a -%= b | Wraps on underflow |
| Saturating Subtraction | a -| b · a -|= b | Clamps on underflow |
| Negation | -a | Two's-complement negation |
| Wrapping Negation | -%a | Wraps on overflow |
| Multiplication | a * b · a *= b | Integers & floats |
| Wrapping Multiplication | a *% b · a *%= b | Wraps on overflow |
| Saturating Multiplication | a *| b · a *|= b | Clamps on overflow |
| Division | a / b · a /= b | Integers & floats |
| Remainder Division | a % b · a %= b | Modulo |
| Bit Shift Left | a << b · a <<= b | Shift left |
| Saturating Bit Shift Left | a <<| b · a <<|= b | Clamps shifted-out bits |
| Bit Shift Right | a >> b · a >>= b | Shift right |
| Bitwise And | a & b · a &= b | Bitwise AND |
| Bitwise Or | a | b · a |= b | Bitwise OR |
| Bitwise Xor | a ^ b · a ^= b | Bitwise XOR |
| Bitwise Not | ~a | Bitwise complement |
| Defaulting Optional Unwrap | a orelse b | Use b when a is null |
| Optional Unwrap | a? | Unwrap, else compile error |
| Defaulting Error Unwrap | a catch b · a catch |err| b | Handle errors with a default |
| Logical And | a and b | Short-circuit AND |
| Logical Or | a or b | Short-circuit OR |
| Logical Not | !a | Negate a boolean |
| Equality | a == b | Equal |
| Null Check | a == null | Optional is empty |
| Inequality | a != b | Not equal |
| Non-Null Check | a != null | Optional has a value |
| Greater Than | a > b | Ordering |
| Greater or Equal | a >= b | Ordering |
| Less Than | a < b | Ordering |
| Lesser or Equal | a <= b | Ordering |
| Pointer Dereference | a^ | Read through a pointer |
| Address Of | &a | Take a pointer |
| Error Set Merge | a || b | Merge error sets |
Precedence
From highest to lowest binding:
x() x[] x.y x^ x? a!b x{} !x -x -%x ~x &x ?x * / % ** *% *| || + - ++ +% -% +| -|
<< >> <<| & ^ | orelse catch == != < > <= >= and or = *= *%= *|= /= %= +=
+%= +|= -= -%= -|= <<= <<|= >>= &= ^= |=
Functions
A function is a reusable block of code. Tinoc supports plain functions and functions with one or more generic parameters.
Syntax
fn <identifier>() <type> {…}
fn <identifier>(<param> <type>, …) <type> {…}
fn <identifier>:T() <type> {…}
fn <identifier>:T(<param> <type>, …) <type> {…}
fn <identifier>:(T, …)() <type> {…}
fn <identifier>:(T, …)(<param> <type>, …) <type> {…}
Calling
<identifier>();
<identifier>(value, …);
<identifier>:T();
// and the same for the other forms…
Example
main.tnc
#import std.io;
fn add(a i8, b i8) i8 {
return a + b;
}
fn greet() void {
io.println("Hi");
}
fn Identity:T(val T) T {
return val;
}
fn main() void {
const result = add(10, 25);
greet();
io.println("result = {d}", result);
io.println("{any}", Identity:str("Tinoc"));
}
Types
Tinoc's type system covers primitives, compounds, pointer, optional, and error types, plus heap-allocated collections.
Integer types
| Tinoc | C equivalent | Category |
u8 | uint8_t | Unsigned integer |
u16 | uint16_t | Unsigned integer |
u32 | uint32_t | Unsigned integer |
u64 | uint64_t | Unsigned integer |
u128 | __uint128_t (GCC/Clang) | Unsigned integer |
usize | size_t | Unsigned, platform-width |
i8 | int8_t | Signed integer |
i16 | int16_t | Signed integer |
i32 | int32_t | Signed integer |
i64 | int64_t | Signed integer |
i128 | __int128_t (GCC/Clang) | Signed integer |
isize | ptrdiff_t | Signed, platform-width |
Floating-point types
| Tinoc | C equivalent | Category |
f32 | float | Float |
f64 | double | Float |
f128 | __float128 (GCC/Clang) | Float |
Core primitives
| Tinoc | C equivalent | Category |
bool | _Bool / stdbool.h | Boolean |
char | uint32_t | Unicode codepoint |
void | void | Void |
String
str has no direct C primitive. It is a plain struct:
| Tinoc | C equivalent | Category |
str | struct { const char* data; size_t len; } | String (struct) |
Arrays & slices
| Tinoc | C equivalent | Category |
[N]T | T arr[N] | Fixed-size array |
[_]T | T arr[N] (size inferred) | Inferred-size array |
[]T | struct { T* ptr; size_t len; } | Slice (fat pointer) |
Pointer
| Tinoc | C equivalent | Category |
^T | T* | Pointer |
Optional
An optional is a nullable wrapper, not a raw pointer:
| Tinoc | C equivalent | Category |
?T | struct { T value; bool has_value; } | Optional |
Error unions
Errors are first-class values. A result carries the value or the error:
| Tinoc | C equivalent | Category |
!T | struct { T value; int err; bool is_err; } | Inferred error union |
E!T | struct { T value; E err; bool is_err; } | Explicit error union |
Compounds
| Tinoc | C equivalent | Category |
struct | struct | Compound |
enum | enum + int | Compound |
union | union | Compound |
Heap collections (std.collections)
Generic library types with no single C equivalent; they use :T and :(K, V) syntax:
| Tinoc | C equivalent | Category |
hstr | None — heap-managed str | Heap string |
vec:T | None — dynamic array | Heap collection |
map:(K, V) | None — hash map | Heap collection |
set:T | None — hash set | Heap collection |
Structs
A struct is a compound type with fields, methods, and static methods.
Syntax
struct <identifier> {
<field> <type>;
…
fn <identifier>(self ^<type-of-struct>, …) <type> {…}
static fn <identifier>() <type> {…}
static fn <identifier>(<param> <type>, …) <type> {…}
}
Example
main.tnc
#import std.io;
// simple struct with method
struct Point {
x f32;
y f32;
fn translate(self ^Point, dx f32, dy f32) void {
self^.x += dx;
self^.y += dy;
}
fn length(self ^Point) f32 {
return sqrt(self^.x * self^.x + self^.y * self^.y);
}
}
// generic struct with method
struct Pair:T {
first T;
second T;
fn swap(self ^Pair:T) void {
var tmp T = self^.first;
self^.first = self^.second;
self^.second = tmp;
}
}
// multi-param generic struct
struct Map:(K, V) {
key K;
value V;
}
fn main() void {
var p Point = Point { .x = 1.0, .y = 2.0 };
p.translate(0.5, 1.5);
var pair Pair:i32 = Pair:i32 { .first = 10, .second = 20 };
pair.swap();
io.println("{any}", p);
}
Enums
Enums are first-class and can carry data, methods, and static methods.
Example
main.tnc
#import std.{io, collection.*};
enum Direction {
North, East, South, West,
}
// enum with data and method
enum Shape {
Circle(f32), // radius
Rect(f32, f32), // width, height
Point,
fn area(self ^Shape) f32 {
// match on self variant
}
}
enum Literal {
String(hstr),
Integer(usize),
// …
// enums can also have methods and static methods, like structs
}
enum Something:T {
First,
Second(T),
}
fn main() void {
const direction1 = Direction.North;
io.println("{any}", direction1);
const literal1 = Literal.String(hstr.from("String Literal"));
io.println("{any}", literal1); // prints: String("String Literal")
var s Shape = Shape.Circle(5.0);
var a f32 = s.area();
}
Unions
A union overlays multiple views over the same memory. Unions can have methods and generic parameters.
Syntax
union <Name> { … } // simple union with methods
union <Name>:<T> { … } // generic union with methods
union <Name>:(<T>, <U>, …) { … } // multi-param generic union
Example
main.tnc
// simple union with method
union Data {
as_int i32;
as_float f32;
as_bytes [4]u8;
fn zero(self ^Data) void {
self^.as_int = 0;
}
}
// generic union with method
union Either:T {
value T;
raw u64;
fn clear(self ^Either:T) void {
self^.raw = 0;
}
}
// multi-param generic union
union OneOf:(A, B) {
a A;
b B;
}
fn main() void {
var d Data;
d.as_int = 42;
d.zero();
// d.as_float now reads the same memory as f32
}
Arrays
Array literals
arrays.tnc
// array literal
const message = ['h', 'e', 'l', 'l', 'o'];
// alternative initialization using a result location
const alt_message [5]u8 = ['h', 'e', 'l', 'l', 'o'];
Multidimensional arrays
matrix.tnc
const mat4x5 [4][5]f32 = [
[1.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 1.0, 9.9],
];
Sentinel-terminated arrays
The syntax [N:x]T describes an array with a sentinel element of value x at the index corresponding to length N.
sentinel.tnc
#import std.testing.expectEqual;
test "0-terminated sentinel array" {
const array [_:0]u8 = [1, 2, 3, 4];
try expectEqual([4:0]u8, @TypeOf(array));
try expectEqual(4, array.len);
try expectEqual(0, array[4]);
}
Destructuring arrays is still being designed. Progress is tracked in the issue tracker.
Switch
switch matches on integer or enum values. There are no parentheses and no fallthrough.
Syntax
switch <expression> {
<value> => { … }
<value> => { … }
_ => { … } // default case
}
Example
main.tnc
enum TokenKind {
Int,
Ident,
Plus,
Eof,
}
fn main() void {
var tok TokenKind = TokenKind.Plus;
switch tok {
TokenKind.Int => { /* handle int */ }
TokenKind.Ident => { /* handle ident */ }
TokenKind.Plus => { /* handle plus */ }
_ => { /* default */ }
}
var x i32 = 2;
switch x {
1 => { /* one */ }
2 => { /* two */ }
_ => { /* anything else */ }
}
}
- No parentheses around the expression.
_ is the default case.
- No fallthrough; each arm is independent.
- Braces are required per arm.
If / Else
Conditional branching with no parentheses around the condition.
Syntax
if <condition> { … } // simple if
if <condition> { … } else { … } // if / else
if <condition> { … } else if <condition> { … } else { … } // chain
Example
main.tnc
fn main() void {
var x i32 = 10;
if x > 0 {
// positive
}
if x > 0 {
// positive
} else {
// zero or negative
}
if x > 0 {
// positive
} else if x == 0 {
// zero
} else {
// negative
}
}
- No parentheses around the condition.
- Braces are required; there is no single-line braceless form.
For
Iterate over ranges and collections with a capture binding.
Syntax
for <start>..<end> |<i>| { … } // range, capturing index
for <collection> |<item>| { … } // collection, capturing item
Example
main.tnc
fn main() void {
// range loop — 0 to 9
for 0..10 |i| {
// i goes 0, 1, 2, … 9
}
// range with a step is done with while, if needed
var i i32 = 0;
while i < 10 {
i += 2;
}
// iterate over a slice
var nums [5]i32 = [1, 2, 3, 4, 5];
for nums |n| {
// n is each element
}
}
- No parentheses around the range or collection.
|i| captures the loop variable. It is a binding, not a closure.
- Range
0..10 is exclusive on the right: it iterates 0 through 9.
break exits; continue skips to the next iteration.
While
Condition-based loops. while true covers both the infinite loop and do-while patterns.
Syntax
while <condition> { … } // loop while condition is true
while true { … } // infinite loop
Example
main.tnc
fn main() void {
var i i32 = 0;
// normal while
while i < 10 {
i += 1;
}
// infinite loop — break to exit
while true {
if i == 20 {
break;
}
i += 1;
}
// do-while equivalent — run the body first, check at the end
while true {
i += 1;
if i >= 5 {
break;
}
}
}
- No parentheses around the condition.
- Braces are required.
while true replaces both infinite loops and do-while patterns from C.
break exits; continue skips to the next iteration.
Modules
#import resolves modules at compile time, not by text insertion like #include.
Syntax
#import <module.path>; // import a specific module
#import <module.path>.*; // import all public exports
Example
util.tnc
pub fn add(a i32, b i32) i32 {
return a + b;
}
main.tnc
#import std.io;
#import util;
#import std.collections.*;
fn main() void {
// std.io symbols accessed via the module name
io.println("Hello, Tinoc");
// std.collections.* symbols available directly
var v vec:i32;
const result = util.add(125, 2022);
io.println("{any}", result);
}
#import replaces C's #include. It resolves modules semantically instead of inserting file text.
.* imports all public exports from a module into the current scope.
pub marks a function, struct, enum, union, const, etc. as public for import.
- Without
.*, symbols are accessed via the last path segment (e.g. std.io → io.println).
Preprocessor
Directives are marked with a # prefix and run at compile time.
| Directive | Purpose |
#import | Compile-time module resolution. Pass the main file (e.g. main.tnc) and the compiler handles the rest. |
#run | Compile-time execution as an expression or block. Useful for metaprogramming. |
#partial | Tells the compiler about a partial implementation of a switch on an enum. |
More directives may be added in future versions.