Important
Slim is still early in development. Please report bugs in Issues and send pull requests for improvements.
Slim is for projects where data validation needs to stay in the running program. It compiles to JavaScript and works directly with npm packages and the JavaScript ecosystem.
| JavaScript | TypeScript | Slim | |
|---|---|---|---|
| Static checks | No | Yes | Yes, where possible |
| Runtime validation | Manual | Manual or libraries | Built in for typed values and structs |
| Data modelling | Objects and classes | Interfaces and types | Structs, enums, defaults, inheritance, validators |
| API and external data | Trust it or validate manually | Types are erased at runtime | Validate at the boundary with a struct or type |
| Components | Native APIs or a framework | Native APIs or a framework | Components and custom elements |
| Tooling | Depends on the project | Depends on the project | Compiler, formatter, test runner, REPL, dev server, and package manager |
Slim does not aim to replace TypeScript everywhere. It is useful when runtime guarantees and built-in language tools matter.
Slim fits JavaScript projects that need runtime validation at data boundaries: API clients and servers, CLI tools, scripts, UI forms, libraries, and embedded JavaScript environments. It adds structs, validators, and language features that otherwise require separate libraries or conventions.
Requirements:
- Node.js 22+
- Git
Create a project folder, then run:
-
Clone project
git clone https://github.com/cdmtn/slim-lang.git -
Install all deps
npm i npm link
On mac/linux this may cause a permission error, try using
sudo npm link -
Check if all Slim CLI Installed
slmc --version spm --version
-
Create Slim config:
slmc create --config slmc config -S main=index
-
Create
.slimfile:slmc create --file index slmc run
This creates slimconfig.json, sets index as the entry point, then compiles and runs index.slim.
Or scaffold everything at once — slimconfig.json plus a starter index.slim:
slmc initSlim keeps type checks at runtime; they are not erased during compilation.
Examples of runtime data validation structures:
struct User {
name: string | any
id: int
roles: string[] // string array (string[])
}
// Let's assume that the data came from an API
const user = {
name: "John",
id: 3,
roles: [] // null array (null[])
}
User.verify(user) // ❌ StructError: "User.roles" expected string[], got null[]Built-in Operators:
// sizeof
log(sizeof [1, 2, 3]) // 3
log(sizeof { key: "value" }) // 1
// empty
log(empty []) // true
log(empty {}) // true
log(empty null) // true
log(empty [1, 2, 3]) // false
// kindof
log(kindof []) // null[]
log(kindof [1, 2, 3]) // int[]
log(kindof null) // null
log(kindof [1, "hello"]) // array
log(kindof 1.5) // float
// or / and (lower into || and &&)
log(true or false) // true
log(true and false) // false
// lock
const a = { name: "John" }
lock a;
a.name = "Arthur" // ❌ Error: Cannot assign to read only property 'name' of object '#<Object>'Basic enum example:
enum Role {
Member: 0
Helper: 1
Admin: 2
}
const user = {
name: "John",
role: 0
}
if(user.role == Role.Member) log(true) // trueEnum members can also be used in struct annotations:
enum Role {
Member: 0
Helper: 1
Admin: 2
}
const user = {
name: "John",
role: Role.Admin
}
struct User {
name: string
role: Role::Helper
}
User.verify(user) // ❌ StructError: "User.role" expected Role::Helper, got intStruct fields may use enums and other structs as types.
A trailing ? marks a type nullable (T? means T | null | undefined):
let name: string? = null // ok
name = "Slim" // ok
name = 42 // ❌ TypeErrorTuple types validate a fixed-length array element by element:
let pair: [int, string] = [1, "a"] // ok
let bad: [int, string] = [1] // ❌ wrong lengthYou can destructure a typed value; the source is validated before it is unpacked:
struct User { name: string
id: int }
const { name, id }: User = payload // throws if payload is not a valid User
const [x, y]: [int, int] = pointTypes combine with | (union — matches any) and & (intersection — matches all):
type Positive(v) { return v > 0 }
type Even(v) { return v % 2 == 0 }
let n: Positive & Even = 4 // ok
let m: Positive & Even = 3 // ❌ TypeError: expected Positive & EvenSlim ships common validator types you can use in any annotation: email, url, uuid, positive, negative, natural, nonempty.
struct Account {
email: email
balance: positive
}
let id: uuid = "550e8400-e29b-41d4-a716-446655440000"Struct fields can declare defaults. Struct.new(...) fills them in and validates the result:
struct User {
name: string
role: string = "member"
active: bool = true
}
const u = User.new({ name: "Alice" }) // { name: "Alice", role: "member", active: true }A struct can extend another, inheriting its fields and defaults:
struct Admin extends User {
level: int
}
const a = Admin.new({ name: "Bob", level: 9 }) // role defaults to "member"Structs can also carry methods (available on Struct.new(...) instances, inherited through extends):
struct Greeter {
name: string
greet() { return "Hi " + this.name }
}
Greeter.new({ name: "Ada" }).greet() // "Hi Ada"A function can declare its return type with -> Type. Slim checks each return and verifies the value at runtime:
func parse(raw: string) -> int {
return JSON.parse(raw).value
}
parse(`{ "value": 7 }`) // 7
parse(`{ "value": "seven" }`) // ❌ function "parse" must return int, got string: "seven"This works with func, methods, arrows, and async functions. Nested functions keep their own return type:
const double = (n: int) -> int => n * 2
async func load(id: int) -> User {
return await fetchUser(id)
}With JSDoc or declarations enabled, the return type is also available to TypeScript (async becomes Promise<T>).
Array<T>, Set<T>, and Map<K, V> validate their contents. Array<T> and T[] are equivalent:
let ids: Array<int> = [1, 2] // ok
let names: Set<string> = new Set(["a"]) // ok
let ages: Map<string, int> = new Map([["a", 1]]) // ok
let mixed: Array<int> = JSON.parse(`[1, "a"]`) // ❌ rejected: "a" is not an intPromise<T> and bare built-ins (Map, Set, Date, RegExp, Error) are matched by constructor. A promise resolves too late for its contents to be checked.
match returns the branch whose pattern matches the subject. _ is the fallback; without it, an unmatched value returns undefined. Enum members compare by value.
enum Role {
Member: 0
Admin: 2
}
const label = match (user.role) {
Role.Admin => "administrator",
Role.Member => "member",
_ => "guest"
}A case can bind the subject and add a when guard:
const size = match (n) {
x when x > 10 => "big",
x when x > 0 => "small",
_ => "nonpositive"
}A match over an enum has to handle every member, or say it does not with _:
enum Role { Member: 0
Admin: 2 }
match (role) {
Role.Admin => "administrator"
} // ❌ match on "Role" does not handle Role.Member — add the missing case or a "_" fallbackSlim skips this check for guarded cases and non-enum subjects.
An uncaught top-level error is reported and exits the process. Use a handler when the process should stay alive:
onError((err) => {
log("handled:", err.message)
})Write tests in .slim files named *.test.slim using the built-in test, assert, and assertEqual:
test("addition works", () => {
assertEqual(2 + 2, 4)
})Run them all with:
slmc testReindent Slim source (strings, templates, and comments are left untouched):
slmc fmt indexBindings persist between REPL lines:
slmc replslim> struct User { name: string }
slim> log(User.new({ name: "Ada" }).name)
Ada
Before building, Slim checks literals, arithmetic, arrays, struct instances, and typed parameters. A definite conflict is a compile error, so no output is written to dist:
TypeError: "User.age" expects int, got string
at index.slim:10:37
const u: User = { name: "Ada", age: "old" }
^It catches invalid literals and reassignments, invalid struct fields, missing field access, array or tuple element errors, incompatible returns, incomplete enum matches, and calls with the wrong arguments:
struct User {
name: string
age: int
}
func greet(user: User, times: int) { return user.name }
let n: int = 1.5 // ❌ expects int, got float
const u: User = { name: "Ada", age: "old" } // ❌ "User.age" expects int, got string
greet(u) // ❌ expects 2 arguments, got 1
log(u.nmae) // ❌ "User" has no field "nmae"Inference is intentionally conservative. Calls into npm packages, unknown imports, and custom type validators remain unknown and are checked at runtime. Slim reports only conflicts it can prove, so normal narrowing with unions remains valid:
let v: int | string = 1
if (kindof v == "int") {
let n: int = v // fine — the runtime confirms it
}
let b: bool = v // ❌ no arm of int | string can ever be a boolChecks follow use across files, so imported structs, enums, custom types, and function signatures are checked at their call sites:
// models.slim
export struct User {
name: string
role: Role
}
export func greet(user: User) -> string { return user.name }// index.slim
use { User, greet } from "./models"
const u: User = { name: 1 } // ❌ "User.name" expects string, got int
let n: int = greet(u) // ❌ "n" expects int, got stringInherited struct fields and types used only inside an imported module are also resolved. A local declaration takes precedence over an imported name.
Static checks run before runtime validation. Disable them with slmc build --no-check or in the config:
{
"main": "index",
"check": false
}Typed variables, parameters, and struct fields are validated at runtime by default. Production builds can remove those implicit checks while keeping explicit struct.verify() calls, operators, and lock:
slmc run --releaseThis sets SLIM_RELEASE=1. You can use the same variable when running compiled output directly:
SLIM_RELEASE=1 node dist/index.jsSlim can add JSDoc to compiled JavaScript so editors and tsc --checkJs understand Slim types. Enable it in slimconfig.json:
{
"main": "index",
"jsdoc": true
}Structs, typed functions, and typed variables then compile to plain JS with JSDoc:
/**
* @typedef {{ name: string, id: number, roles: string[] }} User
*/
/** @param {User} user */
function greet(user) { /* runtime check + return */ }
/** @type {number} */
let count = 0Each struct provides a runtime validator and a static type. int and float map to number, bool to boolean, and optional fields to field?. Enums and custom types emit typedefs too.
With jsdoc enabled, Slim adds // @ts-check, writes dist/external/slim-globals.d.ts, and creates jsconfig.json when one does not exist:
npx tsc -p jsconfig.jsonTypeScript flags invalid arguments and property access. Slim continues to validate typed variable initializers and reassignments at runtime.
For a Slim library consumed from TypeScript, enable declaration files. Each compiled module receives a .d.ts sidecar for exported structs, enums, custom types, and typed functions:
{
"main": "index",
"declarations": true
}Point package.json at the generated entry declaration so consumers pick it up:
{
"types": "dist/index.d.ts"
}A TypeScript project can then import User and greet from the package with type checking intact.
Mark a component as element to compile it as both a custom element and a function. Tab becomes slim-tab, TabBar becomes slim-tab-bar, and the tag is available as Tab.tag:
element component Tab(props) {
onMount((host) => {
host.setLabel = (text) => { host.querySelector(".label").textContent = text }
})
onConnect((host) => { log("in the document") })
onUnmount((host) => { log("removed") })
return <div class="tab"><span class="label">${props.label}</span></div>
}<slim-tab label="Overview"></slim-tab>The host element is the mount target, and onMount receives it. In this example, host.setLabel becomes a method of <slim-tab>:
document.querySelector("slim-tab").setLabel("…").
element also keeps the component callable as a regular function:
EmptyState({ icon: "inbox" }) // the rendered element, as before
document.createElement("empty-state") // the same component, as a tagonConnect maps to connectedCallback; onUnmount maps to disconnectedCallback.
Interpolating a component preserves the same DOM node, including its methods and listeners:
component SideLeft(props) {
onMount((el) => { el.setActive = (id) => { /* … */ } })
return <nav class="side-left">${props.items}</nav>
}
element("music-left") component MusicLeft() {
return ${SideLeft({ items: playlists() })}
}document.querySelector("music-left").setActive(…) reaches the inner component method.
Props can come from attributes or the props property. Properties take precedence, accept any value, and trigger a re-render when reassigned. Values set before the element upgrades are preserved:
const tab = document.createElement("slim-tab")
tab.props = { label: "Overview", items: [1, 2, 3] }
document.body.append(tab)A tag renders on connection and when props changes. There is no hydration; server output is markup, not state. On the server or in an embedded engine, registration is skipped and the component remains a function.
Slim uses one of two runtimes per file:
external/core.js— the type system, structs, enums, operators, validators and test helpers. It imports no Node built-in, touches no filesystem, and assumes no DOM. Everyprocessreference is guarded, so the module loads whereprocessdoes not exist at all.external/defaults.js— the core plus the DOM layer components render through (linkedom on the server) and the file reading that puts a source line in an error.
Files without components use core.js, which also works in hosts such as QuickJS:
struct Enemy {
name: string
hp: positive
}
func spawn(payload: string) -> Enemy {
return JSON.parse(payload) // ❌ throws if the host sent a bad shape
}The output is plain ES modules. A host with module support (for QuickJS, JS_SetModuleLoaderFunc) needs:
console.log, whichlog()and friends call. QuickJS'sqjshas it; an embedder linkinglibquickjsusually defines it.- A module loader, resolving
./external/core.jsrelative to the entry.
Data from the host is validated when it reaches a Slim struct. Where performance matters, use release mode and keep explicit verify() calls.
slmc server watches the project and rebuilds on .slim changes (node_modules, dist, and .git are ignored). Point the watcher at a specific folder with the watch key in slimconfig.json:
{
"main": "index",
"watch": "src"
}Slim compiles to plain ES modules, so npm packages work directly. use resolves a bare specifier to an installed package when no local .slim file has that name:
use { Command } from "commander" // → import { Command } from "commander"
A plain JavaScript import also passes through. Use a struct or type to validate data returned by an external library:
use { fetchUser } from "some-api-client"
struct User {
name: string
id: int
}
const user: User = await fetchUser(3) // ❌ throws if the payload is not a valid User
spm i <name> records each installed package's resolved version and repository in spm.lock.json. Inspect them with:
spm lock