Skip to content

R-unic/loom

Repository files navigation

Loom

CI Status Coverage Status License: Apache-2.0

A domain-specific language for Roblox that transpiles to Luau.

⚠️ This project is a work-in-progress. Nothing is final. Breaking changes may occur at any time. Expect bugs.

Features

  • Immutability by default – Variables, fields, and arrays are immutable unless explicitly marked mut
  • Structural type system – Duck typing with compile-time safety
  • Modern syntax – Familiar syntax inspired by Rust and TypeScript
  • Rich type inference – Minimal annotations required
  • Extended number literals – Automatic math for units of time and frequency, as well as binary/octal/hex support
  • Range expressions1..10 for slicing and bounds
  • nameof operator – Get names as strings at compile time. See example.
  • Flow-sensitive typing - Loom supports discriminated unions and narrowing to the correct union member based on a common property
  • Generic functions and types – Full support for type parameters including constraints and defaults
  • Result pattern for errors – Error handling uses the result pattern from Rust, no more pcalls. See example.
  • Traits – Define reusable behavior that interfaces can implement, enabling shared APIs and generic constraints that reflect behavior
  • Indices starting at one – Same as Luau for familiarity
  • Zero-cost abstractions – Transpiles to idiomatic Luau with minimal overhead
  • Batteries included - Comes with a set of built-in compile-time macros included with data types such as Array.join() or Range.clamp()

Upcoming Features

  • typeof
  • x in collection
  • defer statements
  • Event declarations
  • Full module system (imports/exports)
  • Roblox type generator + Luau typings

Working Examples

Each example is separated by a line. Top code is written in Loom, bottom code is the Luau output.

let x: bool = false;
const x: boolean = false

mut x = 1;
local x = 1

let s = "abc" + "def";
local s = "abc" .. "def"

let x = 1 & 2 & 3;
local x = bit32.band(1, 2, 3)

type Union<A, B> = A | B;
let x: Union<bool, string> = false;
type Union<A, B> = A | B
const x: Union<boolean, string> = false

Loom supports extended number literals that let you do boilerplate math to convert to a specific unit instantaneously.

let a = 10s;
let b = 100ms;
let c = 10m;
let d = 1h;
let e = 16hz;
let f = 100_000_000
let hex = 0xF00D;
let binary = 0b11001;
let octal = 0o400;
const a = 10
const b = 0.1
const c = 600
const d = 3600
const e = 0.0625
const f = 100000000
const hex = 61453
const binary = 25
const octal = 256

mut x = 69;
x = 420;
local x = 69
x = 420

mut x = 69;
mut y = 420;
let z = x = y = 1;
local x = 69
local y = 420
y = 1
x = y
const z = x

Loom supports shorthand function bodies that return single expressions.

fn one -> 1;
const function one()
	return 1
end

fn id<T>(value: T) -> value;
const function id<T>(value: T)
	return value
end

fn id<T: number>(value: T): T {
    return value;
}
id::<number>(69)
const function id<T>(value: T & number): T & number
	return value
end
id(69)

let arr: number[] = [1, 2, 3];
const arr: { number } = {1, 2, 3}

Arrays are immutable by default, but can be declared as mutable.

let arr: number[mut] = mut [1, 2, 3];
const arr: { number } = {1, 2, 3}

Assignments are expressions in loom.

let arr = mut [1, 2, 3];
let x = arr[1] = 69;
const arr: { number } = {1, 2, 3}
const x = 69
arr[1] = x

nameof

The nameof operator can be used to read the tokens of Name expressions as a string.

let abc = 69;
let name = nameof(abc)
const abc = 69;
const name = "abc"

let range = 1..10;
let name = nameof(range.minimum);
const range = { minimum = 1, maximum = 10 }
const name = "range.minimum"

Ranges are constructs that represent a minimum and a maximum number.

let range = 1..10;
const range = { minimum = 1, maximum = 10 }

They can be used to slice arrays.

let range = 1..3;
let arr = [1, 2, 3, 4, 5];
let slice = arr[range];
const range = { minimum = 1, maximum = 3 }
const arr = {1, 2, 3, 4, 5}
const _length = #arr
const slice = table.move(arr, math.clamp(range.minimum, 1, _length), math.clamp(range.maximum, 1, _length), 1, {})

let arr = [1, 2, 3, 4, 5];
let slice = arr[1..3];
const arr = {1, 2, 3, 4, 5}
const _length = #arr
const slice = table.move(arr, math.clamp(1, 1, _length), math.clamp(3, 1, _length), 1, {})

As well as strings.

let s = "abcdef";
let slice = s[1..3];
const s = "abcdef"
const slice = string.sub(s, 1, 3)

let s = "abcdef";
let char = s[1];
const s = "abcdef"
const char = string.sub(s, 1, 1)

let min = (1..10).minimum;
const min = ({ minimum = 1, maximum = 10 }).minimum

Enums are named compile-time constants.

enum Abc { A, B = 69, C }
let a = Abc.A;
let b = Abc.B;
let c = Abc.C;
type Abc = number
const a = 0
const b = 69
const c = 70

They can also be used with strings.

enum Tag: string {
    Lava = "lava",
    Something = "something"
}
let tag = Tag.Lava
type Tag = "lava" | "something"
const tag = "lava"

if 69 == 420 {
    let foo = 69
} else if 69 == 69 {
    let yes = "yes"
}
if 69 == 420 then
	const foo = 69
elseif 69 == 69 then
	const yes = "yes"
end

Declare statements allow you to declare types for symbols that may not exist in your file but you know exist in your environment.

declare fn print(msg: unknown): void;
print("hello, world!");
print("hello, world!")

declare let x: number;
let y = x + 1;
const y = x + 1

let unknown = 69 as unknown;
const unknown = (69 :: unknown)

type Callback = fn(): void
type Callback = () -> ()

interface HasName {
    name: string;
}

interface HasAge {
    age: number;
}

interface Person

:
HasName, HasAge
{
    job: string;
}
type HasName = {
	read name: string;
}
type HasAge = {
	read age: number;
}
type Person = HasName & HasAge & {
	read job: string;
}

interface ImmutRecord<K, V> {
    [K]: V;
}
type ImmutRecord<K, V> = { read [K]: V }

In this example S resolves to string.

interface Foo {
    bar: string
}

type S = Foo["bar"];
type Foo = {
	read bar: string
}
type S = index<Foo, "bar">

interface Person {
    name: string;
    mut
    age: number;
}

let runic = new Person
{
    name: "Runic", age
:
    21
}
;
runic.age = 69;
type Person = {
	read name: string,
	age: number
}
const runic = { name = "Runic", age = 21 }
runic.age = 69

mut i = 0;
while i < 10
    i += 1;
    
print(i)
local i = 0
while i < 10 do
	i += 1
end
print(i)

In this example Foo is only a type and cannot be instantiated.

declare interface Foo {
    bar: string
}
type Foo = {
	read bar: string
}

In this example Foo cannot be used as a constraint to other interfaces.

sealed interface Foo { bar: string }
type Foo = {
	read bar: string
}

After statements are a shorthand to task.delay. They never yield.

after 100ms {
    print("done!");
}
task.delay(0.1, print, "done!")
after 250ms {
    let computed = 69 + 420;
	print(computed);
}
task.delay(0.25, function(): ()
	const computed = 69 + 420
	print(computed)
end)

let collection = [1, 2, 3, 4];
for v, i :
collection
{
    print(i);
    print(v);
}
const collection = {1, 2, 3, 4}
for i, v in collection do
	print(i)
	print(v)
end

for n :
1.
.10
print(n)
for n in 1, 10 do
	print(n)
end

for n :
10.
.1
print(n)
for n in 10, 1, -1 do
	print(n)
end

let condition = true
let value = condition ? 69 : none;
const condition = true
const value = if condition then 69 else nil

In this example K resolves to number | "bar" | "baz".

interface Foo {
    [number]: string;
    bar: string;
    baz: number;
}

type K = keyof (Foo);
type Foo = {
	read [number]: string,
	read bar: string,
	read baz: number
}
type K = keyof<Foo>

Result Pattern

fn unsafe_function(condition: bool): Result<number, string> ->
    condition ? Result.ok(69) : Result.err("function failed!");
    
let result = unsafe_function(true);
print(result.ok ? result.value : result.error);
const function unsafe_function(condition: boolean): Result<number, string>
	return if condition then { ok = true, value = 69 } else { ok = false, error = "function failed!" }
end
const result = unsafe_function(true)
print(if result.ok then result.value else result.error)

Array.join()

let arr = [1, 2, 3, 4];
print(arr.join())
print(arr.join(", "))
const arr = {1, 2, 3, 4}
print(table.concat(arr))
print(table.concat(arr, ", "))

let arr = [1, 2, 3, 4];
print(arr.length)
const arr = {1, 2, 3, 4}
print(#arr)

Mutable arrays support in-place methods (push, pop, insert, remove), and every array supports index_of and has.

let arr = mut [1, 2, 3];
arr.push(4);
arr.insert(1, 0);
arr.pop();
arr.remove(1);
print(arr.index_of(2));
print(arr.has(2))
const arr = {1, 2, 3}
table.insert(arr, 4)
table.insert(arr, 1, 0)
table.remove(arr)
table.remove(arr, 1)
print(table.find(arr, 2))
print(table.find(arr, 2) ~= nil)

print((1..10).length)
print(10)

let range = 1..10;
print(range.length)
const range = { minimum = 1, maximum = 10 }
print(1 + math.abs(range.maximum - range.minimum))

Range.clamp()

print((1..10).clamp(5))
print((1..10).clamp(-10))
print((1..10).clamp(6.9 + 4.2))
print(5)
print(1)
print(10)

let range = 1..10;
print(range.clamp(69))
const range = { minimum = 1, maximum = 10 }
print(math.clamp(69, range.minimum, range.maximum))

string() & number()

let digits = string(69420);
let n = number(digits);
const digits = tostring(69420)
const n = tonumber(digits)

let n = number("F00D", 16)
const n = tonumber("F00D", 16)

Traits & implementations

Traits let you define reusable behavior independently of an interface's data. An implement block attaches a trait to an interface, making its methods available on every instance without storing additional fields. During compilation, Loom generates Luau metatables that provide method dispatch while preserving type safety.

trait ToString {
    fn to_string: string;
}

interface User {
    name: string;
    age: number;
}

implement ToString for User {
    fn to_string -> nameof(User) + " { name: ''" + name + "', age: " + string(age) + " }"
}

let user = new User { name: "Runic", age: 21 };
print(user.to_string());
const Loom = require("@game/ReplicatedStorage/include/loom_runtime")
type ToString = {
	to_string: (ToString) -> string,
}
type User = {
	read name: string,
	read age: number,
} & ToString
local ToString_for_User = {}
ToString_for_User.__index = ToString_for_User
ToString_for_User = ToString_for_User :: User
function ToString_for_User.to_string(self: User)
	return "User" .. " { name: ''" .. self.name .. "', age: " .. tostring(self.age) .. " }"
end
const user = setmetatable({ name = "Runic", age = 21 }, Loom.merge_meta(ToString_for_User)) :: User
print(user:to_string())

Traits can also be implemented per generic instantiation. Multiple implementations of the same trait with different type arguments will result in an error.

trait Serialize<T> {
    fn serialize: T;
}

interface User {
    name: string;
    age: number;
}

implement Serialize<string> for User {
    fn serialize -> name + ", " + string(age)
}

let user = new User { name: "Runic", age: 21 };
print(user.serialize());
const Loom = require("@game/ReplicatedStorage/include/loom_runtime")
type Serialize<T> = {
	serialize: (Serialize<T>) -> T,
}
type User = {
	read name: string,
	read age: number,
} & Serialize
local Serialize_string_for_User = {}
Serialize_string_for_User.__index = Serialize_string_for_User
Serialize_string_for_User = Serialize_string_for_User :: User
function Serialize_string_for_User.serialize(self: User)
	return self.name .. ", " .. tostring(self.age)
end
const user = setmetatable({ name = "Runic", age = 21 }, Loom.merge_meta(Serialize_string_for_User)) :: User
print(user:serialize())

Contributing

Contributions are welcome! Please read our Contributing Guide for details on the process for submitting pull requests and building language features.


License

This project is licensed under the Apache-2.0 License - see the LICENSE file for details.

About

roblox dsl

Topics

Resources

License

Contributing

Stars

9 stars

Watchers

0 watching

Forks

Releases

No releases published

Contributors