Repository navigation
Home
JavaWho is a small, Java-like, object-oriented language that compiles to JavaScript. It was built as a two-person course project to learn how object-oriented languages work in practice — specifically how classes, inheritance, type checking, and return-path analysis fit together inside a compiler.
The compiler is written in C# (.NET 10) and currently runs through tokenization, parsing, type checking, and code generation to JavaScript.
- Building and Running
- Syntax and Examples
- Code Generation Notes
- Language Design
- Known Limitations
- Lessons Learned
- Appendix: Formal Syntax
JavaWho is a .NET 10 project written in C#. There are two ways to build and run it, depending on which one you prefer.
- The .NET 10 SDK.
- Either Visual Studio 2026 (or newer with .NET 10 support) or the
dotnetCLI.
JavaWho-Compiler/
JavaWhoCompiler/ The compiler itself: Tokenizer, Parser, TypeChecker
CompilerTests/ xUnit tests for each compiler stage
ConsolePlayground/ Console host used to drive the compiler manually
CodeExamples/ Sample JavaWho programs (.txt)
- Open
JavaWhoCompiler/JavaWhoCompiler.slnxin Visual Studio. - Set
ConsolePlaygroundas the startup project. - Build the solution (Ctrl+Shift+B).
- Run with F5 (debug) or Ctrl+F5 (without debugger).
To run the test suite, open Test Explorer and run the tests in CompilerTests.
To build (from /JavaWhoCompiler):
dotnet build
To run the playground (from repository root):
dotnet run --project ConsolePlayground -- [input-path] [output-path]
To run the tests (from /CompilerTests):
dotnet test
The intended workflow is to load a .txt file from CodeExamples/ or create one on your own, and run it through the ConsolePlayground to generate the JavaScript for the program. Once you have the JavaScript, you can use tools like node or bun to run your program as such:
# node
node out.js
# bun
bun out.js
The ConsolePlayground will run Tokenizer.Tokenize, Parser.Parse, TypeChecker.Check, CodeGenerator.Generate, and write the generated JavaScript to a file or throw an error.
For the full grammar, see Appendix: Formal Syntax. This section walks through the language by example, including a few cases that are intentionally rejected.
class Pet
{
String Name;
Int Age;
init(String name, Int age)
{
Name = name;
Age = age;
}
method Info() Void
{
println("Pet Name: " + Name + ", Age: " + Age);
}
}
Pet john;
john = new Pet("john", 8);
john.Info();
Notes:
-
initis the constructor. Every class must declare one. - Method headers are
method Name(params) ReturnType. The return type comes after the parameter list, not before the name. - Fields are declared at the top of the class with
Type Name;. - All local fields must be assigned in the constructor.
- Statements after the last class are the program's entry point.
Inside a method, fields are accessed by their bare name. this is used to call another method on the same instance:
class Counter
{
Int Value;
init(Int start)
{
Value = start;
}
method Increment() Void
{
this.SetValue(Value + 1);
}
method SetValue(Int v) Void
{
Value = v;
}
}
Counter c;
c = new Counter(0);
c.Increment();
c.Increment();
There is no this.Value field syntax — fields are only accessible by name from inside the class. Outside, all field access goes through methods.
Since all fields must be assigned in the constructor and there is no this.Value field syntax, shadowing a field name in a constructor is prohibited.
class Counter
{
Int Value;
// shadowing a field in the constructor parameters is rejected
init(Int Value)
{
Value = 5;
// shadowing inside the constructor body is also rejected
Int Value;
}
}
class Cat extends Pet
{
Int Lives;
init(String name, Int age, Int lives)
{
super(name, age);
Lives = lives;
}
method LoseLives(Int livesToLose) Boolean
{
Int tempLives;
tempLives = Lives - livesToLose;
if (tempLives < 0)
{
return false;
}
Lives = tempLives;
return true;
}
}
Pet sam;
sam = new Cat("sam", 1, 9);
sam.Info();
Notes:
- A subclass constructor calls
super(...)to run the parent'sinit. The arguments are type-checked against the parent's constructor signature. - The
supercall must be first in the constructor body. -
Pet sam; sam = new Cat(...)is allowed becauseCatis a subtype ofPet. Callingsam.Info()dispatches normally. - A class can extend from JavaWho's built-in types
StringandObject.- By default, a class without
extendswill extend fromObject.
- By default, a class without
super(...) is type-checked against the parent's init signature. Passing the wrong arity or wrong types is rejected:
class Cat extends Pet
{
Int Lives;
init(String name, Int age, Int lives)
{
super(name); // rejected: Pet.init expects (String, Int)
Lives = lives;
}
}
The correct call matches the parent's parameter list:
init(String name, Int age, Int lives)
{
super(name, age);
Lives = lives;
}
class Animal
{
String name;
init(String _name)
{
name = _name;
}
method printName() Void
{
println("Name: " + name);
}
method printInfo() Void
{
this.printName();
}
// overloaded printInfo
method printInfo(String prefix) Void
{
println(prefix);
this.printInfo();
}
}
class Pet extends Animal
{
String owner;
init(String _name, String _owner)
{
super(_name);
owner = _owner;
}
method printOwner() Void
{
println("Owner: " + owner);
}
// shadowed printInfo
method printInfo() Void
{
this.printName();
this.printOwner();
}
}
// Dog inherits methods
class Dog extends Pet
{
init(String _name, String _owner)
{
super(_name, _owner);
}
}
Dog d;
d = new Dog("Coco", "Joe");
d.printInfo("Information:");
/*
prints out:
Information:
Name: Coco
Owner: Joe
*/
Notes:
- No explicit
overridekeyword. To keep things simple, users are allowed to shadow inherited methods rather than needing to explicitly override them. - When shadowing a method, the return type must be the same or a subtype of the original return type. This allows type guarantees when using methods while subtyping. For example:
Animal cat;
cat = new Cat();
Animal friend;
friend = cat.makeFriend();
- Imagine
AnimalandCathave amakeFriendmethod. Sincecatis anAnimalinstance, it needs to be guaranteed thatCat's implementation ofmakeFriendreturns anAnimalor a subtype ofAnimal.
Int a;
a = 0;
while (a < 5)
{
println(a);
a = a + 1;
}
String text;
text = "";
while (text != "aaaa")
{
if (text == "aa")
{
println("double aa");
}
println(text);
text = text + "a";
}
Notes:
-
+is overloaded:Int + IntisInt, and any combination ofStringwithIntorStringisString. -
breakexits the nearest enclosingwhile. There is nocontinue. - The only comparison operator is
<. To express "greater than", swap the operands.
A non-Void method must return on every path:
method Bad(Int x) Int
{
if (x < 0)
{
return 0;
}
// No return on the else path -- type checker rejects this.
}
Adding the missing return makes it valid:
method Good(Int x) Int
{
if (x < 0)
{
return 0;
}
return x;
}
Boolean conditional guards are also checked:
method Good(Int x) Int
{
if (true)
{
// this code path is always hit
return 0;
}
}
method Bad(Int x) Int
{
if (false)
{
// this code path is never hit, rejected
return 0;
}
}
A Void method is not required to return, but it must not return a value:
method Greet() Void
{
println("hi"); // ok
return; // ok
return println(0); // rejected: cannot use a Void call as a value
}
== and != require both operands to share a type relationship. Comparing two unrelated class types is rejected at type-check time:
class Pet
{
String Name;
init(String name) { Name = name; }
}
class Car
{
Int Speed;
init(Int speed) { Speed = speed; }
}
Pet p;
Car c;
p = new Pet("rex");
c = new Car(60);
if (p == c) // rejected: Pet and Car are unrelated types
{
println("?");
}
Cat == Pet is allowed because the two types share a hierarchy (given Cat is a subtype of Pet).
Notably, every type's hierarchy will eventually lead back to Object. However, this does not mean that all types share a hierarchy. Two types only share a hierarchy when one is a subtype of the other.
JavaWho checks that a local variable has been assigned before it is read:
Int x;
println(x); // rejected: x has been declared but never assigned
Assigning first makes it valid:
Int x;
x = 5;
println(x);
Using a variable that is not guaranteed to be assigned:
method Bad(Int x) Void
{
Int y;
if(x < 5)
{
y = 4;
}
println(y); //rejected: since y is not guaranteed to be assigned
}
Adding an else or assigning the variable before the if is okay:
//assignment with an else
method Good(Int x) Void
{
Int y;
if(x < 5)
{
y = 4;
}
else
{
y = 2;
}
println(y); //ok
}
//assignment outside the if
method Good(Int x) Void
{
Int y;
y = 2;
if(x < 5)
{
y = 4;
}
println(y); //ok
}
To allow for certain features and avoid JavaScript issues, there are some notable annotations that needed to be made that effect how JavaWho code is translated into JavaScript.
One major restriction of JavaWho is the lack of this.Value syntax. Since JavaScript has this feature, JavaWho generates this.Value field access in order to be more explicit in its code generation. Here is an example of this in practice:
JavaWho input:
class Pet
{
String Name;
init(String name)
{
Name = name;
}
}
JavaScript output:
class _Pet {
_Name;
constructor(_name) {
this._Name = _name;
}
}As you can see, this. is prepended on any instance field.
Clearly, though, another major change that is made to inputted JavaWho code is the _ prefix on class names, fields, variables, and parameters. This is in place to prevent collisions with JavaScript keywords such as let and also generated method names, which we'll get into next. This allows a user to do the following:
JavaWho input:
// let is a JavaScript keyword
Int let;
let = 5;
JavaScript output:
let _let;
_let = 5;In order to allow for method overloading and overriding in a dynamically typed language like JavaScript, JavaWho annotates method names and method calls during type checking to ensure JavaScript uses the correct methods. Here is an example:
JavaWho input:
class Value
{
Int Val;
init(Int val)
{
Val = val;
}
method add(Int other) Void
{
Val = Val + other;
}
method add(Int left, Int right) Void
{
Val = left + right;
}
}
JavaScript output:
class _Value {
_Val;
constructor(_val) {
this._Val = _val;
}
Int_add_Void(_other) {
this._Val = this._Val + _other;
}
Int_Int_add_Void(_left, _right) {
this._Val = _left + _right;
}
}Given method names are transformed as such:
- Prepend original name with
_separated parameter types.- If parameter list is empty, prepend with
Empty_.
- If parameter list is empty, prepend with
- Append previous step with
_then the return type.
Method calls are transformed in the same manner since JavaWho's type checker retrieves the exact method signature a method call is referencing.
JavaWho translates println statements into JavaScript's console.log. While this works perfectly in most scenarios, a not ideal output is printed when using a JavaScript String object rather than a string literal in a console.log, as JavaScript prints out the object information rather than the expected string. To avoid this, JavaWho wraps the expression argument within the console.log call in a .toString() call. For example:
JavaWho input:
String s;
s = new String("Hello, World!");
// we expect this to print "Hello, World!"
println(s);
JavaScript output:
let _s;
_s = new String("Hello, World!");
console.log((_s).toString());JavaWho started from a skeleton our class made available, called pOOP. Out of the options to select from, we picked the OOP track because object-oriented programming was the topic that felt most worth understanding from the inside. Most languages we already use are object-oriented, so the chance to take one apart — classes, inheritance, dispatch, type rules — and put it back together as a working compiler was the most interesting choice.
The README sums up the goal in one line: "(Pathetic) object-oriented programming. The goal is for us to better understand how object-oriented programming languages work."
We targeted JavaScript because it is everywhere, easy to run without a separate toolchain, and removes a lot of the noise that would otherwise distract from the parts we actually wanted to learn — the type system and the OOP semantics.
The design is deliberately small. We wanted enough surface area to make the OOP semantics interesting (classes, fields, methods, inheritance, super, subtyping) and as little else as we could get away with.
Here are the big parts that we took out:
-
One loop.
whileonly. Nofor, nodo/while. -
One comparison operator.
<only.>,<=,>=were left out because they reduce to<and add no new ideas. -
No logical operators. No
&&,||, or!. Boolean expressions are built out of comparisons and equality. - No arrays, no generics, no statics. None of these change how OOP works, and all of them add real implementation cost.
-
Classes live at the top of the program. All
classdefs come first, then the entry-point statements. There is no nesting and no forward-reference dance.
The two pieces we did spend our complexity budget on are the things that make OOP non-trivial:
-
A type checker that understands subtyping. A
Catcan be assigned to anAnimalvariable, a method can be called on a base-class type and dispatch correctly, andsuper(...)arguments are checked against the parent's constructor. -
Return-path analysis. A method declared as returning a non-
Voidtype must return on every path. The checker walksif/elseand block structure to confirm this, and it also rejects using aVoidcall as a value. - Assign-path analysis. In a similar fashion to return-path analysis, variable assignment is checked to ensure that unassigned variables are not used in an expression. A walk through code paths allows the type-checker to understand when a variable is guaranteed to be assigned at different points in a block of code.
Given the feature set, JavaWho is realistic for:
- Small teaching examples that exercise OOP: linked-list classes, simple inheritance hierarchies, polymorphic dispatch through a base-class variable.
- Programs that fit in one file and don't need I/O beyond
println. - Anything where the point is to demonstrate type rules, subtyping, or return-path analysis.
It is not realistic for:
- Programs that need collections — without arrays you build your own linked list.
- Numerics-heavy work — no
FloatorDouble. - File or network I/O — only
println.
JavaWho is intentionally small. Most of these limits are choices we made to keep the compiler simple for a course project.
- Methods can only be defined inside classes. There are no top-level or free functions.
- All classes must appear at the top of the file. The program is a sequence of class definitions followed by entry-point statements. You cannot interleave them.
- One source file. There is no module or import system.
- No static methods or static fields. Everything is per-instance.
-
Built-in types are limited to
Int,Boolean,Void, andString. Plus any class type the user defines. - No arrays, lists, maps, or other collections. There is no way to hold more than one value of a type without writing your own linked-list-style class.
-
No
null. A class-typed variable is either uninitialized (and unreadable) or holds a real object. - No generics.
-
Only one comparison operator:
<. There is no>,<=, or>=. To express "greater than", swap the operands. -
Comparisons do not chain.
a < b < cis a parse error. Each<expression takes exactly two operands. -
No logical operators. There is no
&&,||, or!. Boolean expressions are built out of comparisons and==/!=. - No bitwise operators.
-
No compound assignment. No
+=,-=,++,--. You writex = x + 1. -
+is the only string operation. No indexing, no length, no substring.
-
whileis the only loop. Nofor, nodo/while, noforeach. -
No
continue. Onlybreak. - No exceptions, no
try/catch. - No
switch.
-
No access modifiers. There is no
public/private/protected. The "all access goes through methods" rule above is what stands in for visibility. - No interfaces or abstract classes. Inheritance is single-parent class inheritance only.
-
No constructor overloading. A class has exactly one
init. - All local fields must be assigned in a constructor. To ensure fields are assigned, each class must assign its local fields in its constructor.
-
No
this.method calls in constructors. To avoid complex assignment checks,this.method calls are prohibited within constructors.
- No optimization pass. This was an explicit non-goal listed in the original proposal.
- No standard library beyond
println. - No warnings, only errors. Certain detections such as unreachable code may be better off as a warning, but at the moment the compiler can only return errors.
If we started over, the biggest change would be the structure of the type checker. The current one works, but adding a new feature — a new statement, a new type rule, a new operator — means touching several places and re-reasoning about cases each time. We would design the next version around something easier to extend: clearer separation between expression typing, statement checking, and environment tracking, and a single place to add a new node's rule rather than several.
This was the part of the project that grew the fastest. The type checker is around 1,700 lines and the parser is around 800. As the language got more interesting (subtyping, super calls, return-path analysis, assignment analysis, void-as-value checks), the cost of every new addition kept climbing.
A pattern we hit repeatedly: write an implementation, write tests for the cases we thought of, watch them pass, move on — and then later, while writing an example program or thinking about a different feature, realize "wait, what happens if someone writes this?" The answer was usually "something wrong, and our tests didn't catch it."
The cases we kept missing the first time were almost always edge cases in control flow and types: a return inside one branch of an if but not the other, an expression statement whose value is Void, equality between two unrelated class types, a super call with the wrong parameters. The fix going forward is to spend more time generating "weird input" before writing the implementation, rather than after.
- Targeting JavaScript. It removed a lot of work that would not have taught us anything we cared about.
- Keeping the language small. Every feature we cut was a feature we did not have to debug.
- C-like syntax over S-expressions. Reading our own example programs was much easier this way.
- Two-person team size. Communication overhead was low, and we could each own a layer (tokenizer/parser vs. type checker) without stepping on each other.
The complete grammar for JavaWho. This is the authoritative version and is kept in sync with the parser.
var is a variable
classname is the name of a class
methodname is the name of a method
str is a string
i is an integer
comment ::= `//` (~newline)* newline
| `/*` (~`*/`)* `*/`
type ::= `Int` | `Boolean` | `Void` | Built-in types
classname class type; includes Object and String
comma_exp ::= [exp (`,` exp)*]
primary_exp ::=
var | str | i | Variables, strings, and integers are
expressions
`(` exp `)` | Parenthesized expressions
`this` | Refers to my instance
`true` | `false` | Booleans
`println` `(` exp `)` | Prints something to the terminal
`new` classname `(` comma_exp `)` Creates a new object
call_exp ::= primary_exp (`.` methodname `(` comma_exp `)`)*
mult_exp ::= call_exp ((`*` | `/`) call_exp)*
add_exp ::= mult_exp ((`+` | `-`) mult_exp)*
comp_exp ::= add_exp ['<' add_exp]
eq_exp ::= comp_exp [('==' | '!=') comp_exp]
exp ::= eq_exp
vardec ::= type var
stmt ::= exp `;` | Expression statements
vardec `;` | Variable declaration
var `=` exp `;` | Assignment
`while` `(` exp `)` stmt | while loops
`break` `;` | break
`return` [exp] `;` | return, possibly void
if with optional else
`if` `(` exp `)` stmt [`else` stmt] |
`{` stmt* `}` Block
comma_vardec ::= [vardec (`,` vardec)*]
methoddef ::= `method` methodname `(` comma_vardec `)` type
`{` stmt* `}`
constructor ::= `init` `(` comma_vardec `)` `{`
[`super` `(` comma_exp `)` `;` ]
stmt*
`}`
classdef ::= `class` classname [`extends` classname] `{`
(vardec `;`)*
constructor
methoddef*
`}`
program ::= classdef* stmt+ stmt+ is the entry point