kopscript
v0.7.0
Published
KopScript: a small OOP, strongly-typed language that transpiles to JavaScript, with generics and nullable types
Maintainers
Readme
KopScript
KopScript is a small, strongly-typed, object-oriented programming language that transpiles to readable JavaScript. It's a hobby/learning project: a from-scratch lexer, parser, type checker, and code generator, written in TypeScript — leaning deliberately toward C# paradigms (type-first declarations, interfaces, explicit virtual/override dispatch, auto-properties, enums) rather than TypeScript ones.
Because KopScript compiles to plain JavaScript and runs on Node, it runs identically on Windows, macOS, and Linux — there's no native toolchain to maintain.
Generating KopScript with an AI coding assistant? Point it at LLM.md —
a dense, complete, example-driven language spec designed to be loaded straight into an
LLM's context, as opposed to this README's narrative explanation.
Highlights
- Object-oriented, C#-flavored: type-first declarations (
string Name;, notname: string), classes with auto-properties, single inheritance plus interfaces viaclass Dog : Animal, IPet, explicitvirtual/overridedispatch (methods are sealed unless markedvirtual), and a fullpublic/protected/privateaccess model enforced at compile time. - Strongly typed: every declaration is explicitly typed and checked at compile time.
- Generics: a single, unconstrained, invariant type parameter on classes and
interfaces (
class Box<T> { public T Value; }) — erased at codegen with zero runtime cost, the same waytask<T>/state<T>already are. - Nullable types with compiler-enforced null-checking:
T?(string?,Dog?,number[]?) — aT?can't be used where aTis expected without anif (x != null)check first (checked statically, not just at runtime), and comparing a value that can never be null tonullis itself a compile error. - Built-in string pattern matching: a
matchexpression over strings supporting literal, wildcard, and regex patterns — noif/elsechains required. - Real multi-file programs:
using "./shapes";compiles a whole dependency graph together, checkingpublic/privatevisibility across files and emitting genuine ESimport/exportstatements — so a compiled KopScript module is also a normal JS module that a plain Node/TS project canimportdirectly. - First-class functions and real closures: lambdas (
(number x) => x * 2), function types ((number, number) => number), and calling any function-valued expression — not just named functions — compiling straight to native JS arrow functions, so closures work exactly like they do in JS. - JS/npm interop:
externdeclarations describe the shape of an existing JS function, class, or global value — ambient (Math,document, no import) or imported from a module specifier — so KopScript code can call real JS APIs, including avirtualmethod on anextern classthat a real KopScript class canoverride, which is what lets a class subclass something from another package/repo entirely. async/await:task<T>(a promise of aT) compiles to a real JSPromise,async/awaitto the real thing, andtry/catch/finally/throwgive a rejected task something to catch.- Reactive state without RxJS:
state<T>is a small reactive box (.Valueget/set,.Subscribe((T) => void)) for holding state and reacting to it changing — no Observables, no operators, no manual unsubscribe bookkeeping. - Real markup templates:
template from "./x.html";compiles a separate, almost-pure-HTML file — interpolation, event/property bindings,*if/*for— down to the exact same AST a hand-writtenRender()would produce, with auto-Subscribewiring for state referenced directly in the markup. See Templates. - A companion framework, Kopular: components, constructor-injected services via a composition root (no DI container), and real-URL routing (no config DSL) — built entirely on the features above, in a separate repo consumed as a real package. KopularDemo is a real app built on both.
Getting started
npm install
npm run ks -- run examples/animals.ks
npm run ks -- run examples/classify.ks
npm run ks -- run examples/features.ks
npm run ks -- run examples/shapes.ks
npm run ks -- run examples/modules/main.ks
npm run ks -- run examples/closures.ks
npm run ks -- run examples/async.ks
npm run ks -- run examples/arrays.ksThese are all plain console scripts, runnable with Node directly. A browser-facing
KopScript project (DOM extern bindings, ks watch + a static file server for a live
reload-free dev loop) is a different setup — see
Kopular and
KopularDemo for a
real example of that workflow.
Or build to a .js file without running it:
npm run ks -- build examples/animals.ksRun the test suite:
npm testLanguage tour
Variables
Declarations are type-first, C# style — the type comes before the name, with no let:
number x = 5;
const string name = "Joe";Functions
Free functions are also type-first, with the return type before the name (no function
keyword — the same shape as a class method, just outside a class):
number Add(number a, number b) {
return a + b;
}
void Announce(string message) {
print(message);
}Functions as values, and closures
A function type is written (ParamType, ...) => ReturnType. Lambdas always have explicit
parameter types (no inference in v1) and never write their own return type — it's checked
against whatever function type the lambda is used where, which KopScript always knows concretely
since every declaration is explicitly typed:
number Apply((number) => number f, number x) {
return f(x);
}
number Square(number x) { return x * x; }
print(Apply((number x) => x * 2, 5)); // 10 — an inline lambda
print(Apply(Square, 4)); // 16 — a named function used as a valueA lambda body can be an expression ((number x) => x * 2) or a block
((number x) => { return x * 2; }). Lambdas are real closures — they capture their
enclosing scope by reference, compiling directly to JS arrow functions, so mutating a
captured variable from inside a lambda is visible to the code that captured it:
number counter = 0;
() => number next = () => { counter = counter + 1; return counter; };
print(next()); // 1
print(next()); // 2Calling any expression whose type is a function type works — a variable, a field, a call that returns a function, not just a named function or method.
Classes, auto-properties, and inheritance
class Animal {
public string Name { get; set; }
constructor(string name) { this.Name = name; }
public virtual string Speak() { return this.Name + " makes a sound"; }
}
class Dog : Animal {
public override string Speak() { return this.Name + " barks"; }
}public string Name { get; set; }is a read-write auto-property;public string Name { get; }is get-only — it can only be assigned within its own class's constructor (like a C# read-only auto-property), and is rejected anywhere else, including other methods of the same class.public/protected/privateare enforced at compile time:privatemembers are only reachable from within the declaring class (any instance, not justthis);protectedmembers are reachable from the declaring class and its subclasses;publicis unrestricted. Fields default topublicif no modifier is written.- Methods are sealed by default, like C#. A subclass may only redefine a method that the
base class marked
virtual, and must mark its own versionoverride; the compiler rejects a redefinition that omitsoverride, anoverridewith no matchingvirtualbase method, or a signature mismatch between the two. staticfields and methods belong to the class itself, accessed asClassName.Member(never through an instance orthis) and shared across every instance. A static field requires an inline initializer, since there's no constructor to assign it in:private static number Count = 0;.staticcannot be combined withvirtual/override, and static auto-properties aren't supported in v1 — use a static field instead.- A subclass that doesn't declare its own constructor inherits the superclass's (the same
way JavaScript's
extendsworks). Declaring a constructor in a subclass that extends another class is not supported in v1.
Interfaces
class Foo : Base, IBar, IBaz mixes at most one base class with any number of interfaces
in a single colon-separated list — the compiler figures out which name is which. A class
implementing an interface must provide every method the interface declares, with a
matching signature. Interfaces can themselves extend other interfaces
(interface INamedShape : IShape { ... }), which pulls in the parent's method
requirements too; implementing classes must satisfy the whole chain. Interfaces only
declare method signatures in v1 — no properties.
interface ISpeaker {
string Speak();
}
class Dog : ISpeaker {
public string Speak() { return "Woof"; }
}
void Announce(ISpeaker s) {
print(s.Speak());
}Generics
Classes and interfaces can take a single type parameter:
class Box<T> {
public T Value;
constructor(T v) { this.Value = v; }
public T Get() { return this.Value; }
}
Box<number> nb = new Box<number>(5);
Box<string> sb = new Box<string>("hi");
print(nb.Get()); // 5The type argument is required everywhere the generic type is named — on the variable's
declared type and separately on new (Box<number> nb = new Box<number>(5);, not
just one or the other). Generics are erased at codegen exactly like task<T>/state<T>
already are — Box<number> and Box<string> compile to the identical plain class Box,
so there's no runtime cost.
This is deliberately the smallest useful slice of generics, not a scaled-down promise of more later inside v1:
- One type parameter, invariant, unconstrained. No
Map<K, V>(multiple parameters), noT : ISomething(constraints) — and becauseTis fully unconstrained, you can't call any member on a bareTvalue inside the generic class's own body (that's correct behavior, the same restriction C#/Java/TypeScript put on an unconstrained parameter, not a bug). Invariant meansBox<Dog>is not assignable toBox<Animal>even thoughDog : Animal— type arguments must match exactly. - No generic functions.
T Identity<T>(T x)isn't supported — only classes and interfaces take a type parameter. A method inside a generic class can still use that class's ownTfreely; it's just not introducing a type parameter of its own. - No generic inheritance. A class or interface's base list can only name a
non-generic type.
class Foo : Box<number>andclass Foo<T> : SomeBase<T>are both compile errors — a generic class can still extend/implement ordinary non-generic bases normally, it just can't be the one on either side of a generic base relationship.
See LLM.md's "Generics" section for the exhaustive rules if you're generating code
against this — nested generics (Box<Box<number>>, Box<number>?[]) and generic
interfaces used as standalone parameter types both work and are covered there.
Enums
enum Color { Red, Green, Blue }
Color c = Color.Green;
if (c == Color.Green) { print("It's green"); }Members are numbered from 0 in declaration order, compiling to a frozen JS object.
Modules
using "./shapes"; brings every public top-level declaration from that file (resolved
relative to the current file, no .ks extension) into unqualified scope — there's no
namespace prefix and no picking individual names, the same way a C# using directive
brings a whole namespace into scope. using directives must be a contiguous block at the
very top of the file.
// shapes.ks
interface IShape {
number Area();
}
class Circle : IShape {
public number Radius;
constructor(number radius) { this.Radius = radius; }
public number Area() { return this.Radius * this.Radius * 3; }
}// main.ks
using "./shapes";
Circle c = new Circle(2);
print(c.Area());Every top-level class, interface, enum, and function is public (exported, visible to
files that using this one) by default — mark it private to keep it file-scoped:
private number Helper() { return 42; } // not visible outside this file
class Public { } // visible by defaultTop-level let/const variables are never exportable — only types and functions cross
file boundaries. There's also a structural rule worth knowing: if a class or interface is
exported, everything in its public surface must be exported too (an exported class's base
class and implemented interfaces, an exported interface's base interfaces) — the compiler
rejects public class Derived : Base if Base isn't also public, since otherwise a
file importing Derived would have no way to make sense of its own base type.
ks build/ks run compile the entry file plus everything it transitively usings,
each to its own .js file with real ES import/export statements — so node
main.js (or a plain JS/TS project importing the compiled output directly) just works,
with Node's own module resolution doing the wiring.
Interop with JS/npm (extern)
extern declarations describe the shape of something that already exists in JS, without
providing a KopScript implementation — KopScript trusts the declared types (same trust model as a
TypeScript .d.ts file). There are three forms, and each can be either ambient (no
from — an already-existing global like Math or document, nothing to import) or
imported (from "<module>" — a named export from an npm/Node module):
extern string ReadFileSync(string path) from "node:fs" as "readFileSync"; // function
extern Document document; // value (ambient)
extern class Element { // class — a type shape, describing an existing class
constructor();
string textContent { get; set; }
void addEventListener(string eventType, (Event) => void handler);
static number InstanceCount { get; }
} from "some-dom-lib";as "<jsName>" maps a KopScript-facing name to the real JS-side identifier (defaults to the
declared name if omitted) — needed constantly in practice, since JS naming (camelCase,
readFileSync) rarely matches KopScript's (PascalCase, ReadFileSync). Extern class
members have no separate rename mechanism, though — write them with the exact real JS
name (addEventListener, not AddEventListener), since that's what actually exists at
runtime. Calling an extern method or accessing an extern property compiles exactly like
any other member access — no special codegen, since it isn't reimplementing the class,
just describing one that's already there.
A real, working example: Kopular's
dom.ks declares ambient bindings for document/Element/Event/window/location,
which its Component/Router are built on — and
KopularDemo
consumes Component/Router themselves via extern class ... from "kopular/...";,
proving extern works as a real cross-package boundary, not just for describing DOM
globals within a single project.
An extern class can carry its own type parameter, extern class Box<T> { ... } —
exactly the same single-type-parameter rules as a real generic class (see "Generics"
above: invariant, unconstrained, erased, can't appear in a base list), so a generic type
from another package (e.g. Kopular's FormField<T>) can be described and instantiated
generically, not just per concrete type:
extern class Box<T> {
constructor(T v);
T Value { get; }
} from "some-package";
Box<number> nb = new Box<number>(5);Now that KopScript has async/await and task<T> (see below), a Promise-based JS API is
describable too — extern task<string> Fetch(...) from "..." as "fetch"; is legitimate,
and awaiting it works exactly like awaiting any other KopScript task. What's still not cleanly
describable is old-style Node callback-based async that doesn't return a Promise at all
(fs.readFile(path, callback) with an error-first callback) — nothing in KopScript understands
that specific convention, even though the callback parameter itself is describable as an
ordinary function type.
Compile-time file embedding (raw)
raw string <Name> from "<path>"; reads the file at <path> (resolved relative to the
.ks file that declares it) at compile time and embeds its contents as a plain string
constant — no fetch, no runtime file access, nothing left over at all once compiled.
Top-level only, always string, exported by default like any other top-level declaration
(private keeps it file-scoped):
raw string CounterHtml from "./counter.html";
void Main() {
print(CounterHtml); // the file's exact contents, as a string
}Unlike extern, there's no real JS export being described here — the compiler fabricates
the value itself — so there's no as "jsName" clause. This is a general-purpose,
compile-time-only file embedding primitive — useful for anything that wants a file's
exact contents as a string constant with no runtime file access. (If what you actually
want is markup that becomes real DOM construction code, not just a string — see
Templates below, which is the more specific answer for that case.)
async/await, task<T>, and try/catch
task (a promise of nothing) and task<T> (a promise of a T) are the one hardcoded
parametrized type in v1 — not general generics, just enough to give async/await a
return type. async requires (and is required by) a task/task<T> return type; return
statements inside an async body are checked against the unwrapped result type, exactly
like real C#/JS async functions — you write return 5;, not return SomeTaskOf(5);:
async task<number> Double(number x) {
return x * 2;
}
async task<number> Chain(number x) {
number a = await Double(x);
number b = await Double(a);
return b;
}Both compile directly to their real JS equivalents (async function, await), so an
async KopScript function returns a genuine Promise and composes with plain JS/TS code
without any wrapping. await also works at the top level of a file, matching real
top-level await in an ES module.
Exception handling exists specifically so a rejected task has something to catch:
try {
number result = await MightFail(x);
print(result);
} catch (string message) {
print("caught: " + message);
} finally {
print("done");
}throw accepts any type — there's no base exception/error type to require conformance
to, matching JS's own looseness — and a catch (Type name) parameter's type is trusted,
not verified (the same trust model as extern: KopScript has no way to know what a given throw
site might actually produce). At least one of catch/finally is required; a bare try {}
alone is rejected. examples/async.ks and test/codegen.test.ts exercise a full chain of
async calls, a caught error, and a finally that runs on both the success and failure path.
Known v1 limitations: no async lambdas (await is never valid inside a lambda body, even
inside an async function — only free functions and methods can be async), and no way to
construct a task value directly outside of an async function body.
Reactive state: state<T>
state<T> is the other hardcoded parametrized type in v1, alongside task<T> — a reactive
box holding a T. Construct one with state(initial) (no explicit type argument; T is
inferred from initial), read/write it through .Value, and register a listener with
.Subscribe((T) => void) that fires every time .Value is assigned:
state<number> count = state(0);
count.Subscribe((number v) => print("now " + v));
count.Value = count.Value + 1; // prints "now 1"This exists to give Kopular components a way to re-render on state change without pulling in
anything like RxJS — no Observables, no operators, no manual unsubscribe bookkeeping. A
component subscribes once, in its constructor, to call its own Update():
class Counter : Component {
private state<number> Count;
constructor() : base() {
this.Count = state(0);
this.Count.Subscribe((number v) => this.Update());
}
public override Element Render() {
Element button = document.createElement("button");
button.textContent = "Count: " + this.Count.Value;
button.addEventListener("click", (Event e) => {
this.Count.Value = this.Count.Value + 1; // Update() fires automatically
});
return button;
}
}Compare this to the pre-state<T> version of the same component, which had to call
this.Update(); by hand inside every single event handler that touched state — one
Subscribe call at construction now does that job everywhere.
Kopular's Component
is built around exactly this pattern, and its own README covers the rest of the
"Angular's separation, none of the bloat" story — constructor-injected services instead
of a DI container, and hash-based routing instead of a config DSL — both built entirely
on ordinary KopScript, no further compiler features required beyond state<T> and the
extern/virtual support above.
Hand-writing Render() like this, statement by statement, is still fully supported — but
a Kopular component today more commonly expresses it as a template instead. See
Templates below for the markup-based alternative to this same method, and
for how Count.Subscribe(...) above can often be skipped entirely.
Templates
template from "<path>"; inside a class body replaces a hand-written Render() with a
real, separate markup file — almost pure HTML, with data bindings and a small set of
structural directives standing in for the imperative DOM code above:
class Counter : Component {
public state<number> Count;
constructor() : base() { this.Count = state(0); }
public void Increment() { this.Count.Value = this.Count.Value + 1; }
template from "./counter.html";
}<!-- counter.html -->
<button (click)="Increment()">Count: {{ Count.Value }}</button>This compiles to exactly the Render() method you'd otherwise write by hand — the
template compiler is a pass that runs between parsing and type-checking, turning the
markup into ordinary MethodDecl/statement/expression AST nodes and splicing the result
into the class before checking ever runs. There's no separate runtime template engine, no
virtual DOM diffing, and no interpreted expression language: {{ Count.Value }} and
(click)="Increment()" contain real KopScript, parsed and type-checked exactly like
anything else in the file, with errors reported at their real position in the .html
file, not the .ks file.
A class may have a template or a hand-written Render(), never both — that's a compile
error. ks watch also tracks the referenced .html file, so editing markup alone
triggers a rebuild.
Supported bindings and directives:
| Syntax | Desugars to |
| --------------------------- | --------------------------------------------------------- |
| {{ expr }} (in text) | el.textContent = $"...{expr}..."; (an InterpolatedStringLiteral, same as $"...") |
| (event)="stmt" | el.addEventListener("event", (Event e) => { stmt }); |
| [prop]="expr" | el.prop = expr; — a plain assignment, checked like any other |
| class="..." (static) | el.className = "..."; (aliased, since class is a KopScript keyword) |
| *if="expr" | a real if (expr) { ... } around the element's creation |
| *for="Type var of expr" | a real for (Type var in expr) { ... } — the element type is explicit, matching KopScript's no-inference stance elsewhere (Generics, Nullable types) |
Auto-subscribe: a state<T> field declared directly on the component and referenced
directly in its template (like Count above) gets its Subscribe((v) => this.Update())
wired up automatically — no manual Subscribe call needed in the constructor. This is a
syntactic check against the class's own declared fields, not a type-checker query, so it
only covers direct field access; state reached indirectly — through a method call, or
through this.SomeService.Count — still needs a manual Subscribe, same as before
templates existed.
v1 cuts, same discipline as generics and nullable types: exactly one top-level element per
template (no auto-wrapping — a clear error instead); no mixing text and element children
under one element (Kopular's DOM surface has no text-node type, only .textContent); no
two-way binding, no pipes, no stacking two structural directives on one element.
A deliberate layering note: the template from syntax lives in kopscript's own
grammar (Kopular can't extend a language it doesn't own), but what it desugars to —
document.createElement, .appendChild, .textContent, .addEventListener — assumes
exactly the DOM surface Kopular's
dom.ks declares. That's a real coupling from the compiler to one specific consumer,
accepted deliberately rather than building a generic pluggable desugaring-target system
for a hypothetical second framework that doesn't exist today. If one ever does, that's the
point to generalize this.
raw string <Name> from "./x.html"; (above) still exists as a general-purpose
compile-time file embedding feature — but for the specific "component markup in its own
file" use case, template from is the real answer; raw no longer needs to stand in for
it.
String interpolation
string name = "Joe";
string msg = $"Hello, {name}!";Pattern matching over strings
The headline feature: a match expression with literal, comma-separated, and regex patterns.
A match must end with a _ wildcard arm.
string Classify(string input) {
return match input {
"cat", "dog" => "animal",
r"^[0-9]+$" => "number",
_ => "unknown"
};
}String stdlib
KopScript's string type exposes PascalCase members that map directly onto
String.prototype:
| KopScript | JavaScript |
| ----------------------- | -------------------------- |
| s.Contains(x) | s.includes(x) |
| s.StartsWith(x) | s.startsWith(x) |
| s.EndsWith(x) | s.endsWith(x) |
| s.Replace(a, b) | s.replaceAll(a, b) |
| s.Split(x) | s.split(x) |
| s.Trim() | s.trim() |
| s.ToUpper() | s.toUpperCase() |
| s.ToLower() | s.toLowerCase() |
| s.Length | s.length |
Array stdlib
Arrays expose .Length, plus Map/Filter/ForEach/Push, using lambdas or any other
function-valued expression (a named function, a variable holding one, ...):
number[] xs = [1, 2, 3, 4];
string[] labels = xs.Map((number x) => "n" + x); // ["n1", "n2", "n3", "n4"]
number[] evens = xs.Filter((number x) => x % 2 == 0); // [2, 4]
xs.ForEach((number x) => print(x));
number[] grown = xs.Push(5); // [1, 2, 3, 4, 5]; xs itself is untouchedMap/Filter/ForEach compile straight to their real Array.prototype equivalents.
Push is the one departure from JS: it's non-mutating (returns a new array; xs
itself is unchanged), unlike JS's own Array.prototype.push — chosen for consistency with
Map/Filter (already non-mutating) and because nothing else in KopScript's type system models
aliasing/mutable-reference semantics, so a silently-mutating Push would be a surprising
outlier. It compiles to a plain spread ([...xs, 5]), not a .push() call.
Map's result type is the one genuinely polymorphic piece of the whole language — the
result element type is whatever the callback actually returns, not a fixed signature.
A plain function reference (xs.Map(SomeFunction)) already carries a fully-known type, so
that case is exact; an inline expression-bodied lambda (xs.Map((number x) => ...)) has
its return type inferred from the body. A block-bodied lambda passed to Map is a known
v1 gap — its result type can't be inferred that way yet.
Control flow
if (x > 0) {
print("positive");
} else {
print("non-positive");
}
while (x < 10) {
x = x + 1;
}
for (number i = 0; i < 10; i = i + 1) {
print(i);
}
foreach (number item in items) {
print(item);
}foreach requires an explicit element type (there's no var/type inference in v1).
break and continue work inside while, for, and foreach loops.
Types (v1 scope)
number, string, bool, void, T[] (arrays), class types, interface types, enum
types, function types ((T, ...) => R), nullable types (T?, with null and
compiler-enforced null-checking — see "Nullable types" below), and a single type
parameter on classes/interfaces (Box<T> — see "Generics" above).
Nullable types — T?
string? maybeName = null;
void Greet(string? name) {
if (name != null) {
print("Hello, " + name); // name is `string` here, not `string?`
} else {
print("Hello, stranger");
}
}? is a postfix modifier on any type (Dog?, or an array either way — string?[] is an
array of nullable strings, string[]? is a nullable array of strings; order matters).
It's purely a compile-time distinction — erased at codegen, since JS already has native
null. The compiler enforces it in both directions:
- A
T?can never be used where aTis expected — accessing a member or index on one is a compile error — unless it's been narrowed by anif (x != null)/if (x == null) {...} else {...}check (either operand order, and inside a&&/||short-circuit) on that exact local/parameter. Narrowing doesn't survive past the checked block, doesn't follow athis.Fieldpath (locals/params only), and doesn't understand an early-return guard clause (if (x == null) { return; }) — each of those would need real control-flow analysis, deliberately not taken on in v1. - Comparing a value to
nullwhen its type can't be null (i.e. isn't itself aT?) is a compile error, not a silently-always-false comparison — a real static check, not just a style rule.
See LLM.md's "Nullable types" section for the exhaustive rules if you're generating
code against this.
Built-ins
print(...) compiles to console.log(...).
Architecture
src/
lexer.ts tokenizer: source -> Token[]
tokens.ts token kind enum + Token type
ast.ts AST node type definitions
parser.ts recursive-descent parser: Token[] -> AST (Program)
diagnostics.ts error/warning collection with line/col + source snippets
types.ts type system: Type representations + compatibility rules
checker.ts semantic analysis: scopes, symbol table, type checking over the AST
codegen.ts AST -> JavaScript source string (readable ES2020 output)
modules.ts multi-file orchestration: resolves the `using` graph, checks
modules in dependency order, drives codegen across all of them
cli.ts `ks build|run|watch|check <file>` entry point
examples/ sample .ks programs (examples/modules/ is a multi-file one)
test/ vitest unit and end-to-end testsPipeline, per file: source -> lexer -> parser (AST) -> checker (validates the AST,
collects diagnostics) -> codegen (emits JS). modules.ts sits above this: it parses the
entry file and everything it transitively usings, topologically sorts them
(dependencies first), and checks each one with its direct dependencies' exports seeded in
— so a class from another file resolves exactly like a local one once the checker starts.
Codegen then runs per file, turning each using into a real import statement and each
exported top-level declaration into a real export. The CLI aborts before writing
anything if any file in the graph has errors, and otherwise writes one .js file next
to each .ks source and (for run) executes the entry file's output with node.
Interfaces and the virtual/override discipline are purely compile-time: JS methods are
always dynamically dispatched, so codegen doesn't need to do anything special for either
one — the checker just validates the contract before code is ever emitted. Enums compile
to a small frozen object (Object.freeze({ Red: 0, Green: 1, ... })); interfaces have no
runtime representation at all and are dropped from the emitted JS entirely.
CLI
ks build <file.ks> # type-check and emit <file>.js next to the source
ks run <file.ks> # build, then execute the emitted JS with node
ks watch <file.ks> # build, then rebuild on every change to any file in the graph
ks check <file.ks> # type-check only — no output files writtenbuild and check both accept --json, which replaces all human-readable output with a
single JSON object on stdout — meant for CI or a tool/agent parsing the result
programmatically instead of scraping formatted text:
{
"success": false,
"diagnostics": [
{ "severity": "error", "message": "Argument 2 has type 'string', expected 'number'", "line": 2, "col": 14, "file": "/abs/path/to/file.ks" }
],
"written": []
}written lists the absolute paths of every .js file actually written (always empty for
check, and for build on failure — nothing is written unless the whole graph is
error-free). A missing entry file reports { "success": false, "diagnostics": [],
"written": [], "error": "cannot find file '...'" } instead of throwing. Exit code is 0
exactly when success is true, both with and without --json.
During development, use npm run ks -- <build|run|watch|check> <file.ks> (backed by
tsx), or run npm run build to compile the TypeScript compiler itself to dist/ and
use node dist/cli.js directly.
watch rebuilds on a save to any .ks file it reached while compiling — the entry and
everything it (transitively, non-transitively per-file) usings — not just the entry
file, plus any .html file referenced via template from "..."; in one of those classes,
and re-establishes its watch list after every rebuild since the dependency set
itself can change (a using added or removed, or a template declaration's path). This is the piece that makes a
browser-facing dev loop bearable: run ks watch in one terminal, a static file server in
another, and refreshing the browser after a save is the only manual step left — there's
no watch-triggered auto-refresh, since KopScript has no dev-server integration to push
that to the page. See KopularDemo
for this workflow in practice.
Editor support
editors/vscode/ is a local-install-only VS Code extension providing .ks syntax
highlighting (a TextMate grammar — comments, all three string forms including highlighted
interpolation expressions, keywords, types, function calls) and bracket/comment
configuration. No language server, no Marketplace listing — see editors/vscode/README.md
for installing it locally (Developer: Install Extension from Location..., or symlink it
into your extensions folder).
Status
This is a v1 / hobby-project scope. Nullable types (T?) and generics (a single
unconstrained, invariant type parameter on classes/interfaces) have both shipped — see
above for both. What generics deliberately doesn't cover: multiple type parameters
(Map<K, V>), constraints (T : IFoo), generic functions, generic inheritance, and
variance — each a real, separable extension rather than a v1 oversight. Also not yet
supported: static auto-properties, interface properties (methods only), and nested
functions/classes.
async/await, task<T>, and try/catch/finally/throw are now in place (see the
language tour above) — the ceiling that's left is what's inside those: no async lambdas,
and no way to build a task value by hand outside an async function body.
A frontend framework: Kopular
The language shape — modules, closures, DOM interop, state<T>, and extern/virtual
subclassing across a package boundary — exists to support real UI components, not just
scripts. That framework itself, Kopular,
lives in its own repo, published to npm as kopular and consumed like any other package
(no local checkout or file: dependency needed). A real app built on both lives in
KopularDemo — several
routed pages, state<T>-driven reactivity, constructor-injected services via a
composition root (no DI container), real-URL (History API) routing, and Angular-style
markup templates (see Templates above) with *if/*for structural
directives — verified against a real DOM via jsdom and in an actual browser.
