Classes

Classes are where TypeScript's compile-time-only features cluster: private that any JavaScript caller can read anyway, and parameter types on methods that nothing checks once the code runs. TJS can make both real. It also changes one thing a TypeScript developer will notice at once: a class is called, not constructed with new.

The converter (fromTS in tjs-lang/lang/from-ts, or tjs convert on the command line) turns a .ts file into TJS. It keeps each class, its methods, and the parameter types. What it does with them depends on one comment, /* @tjs TjsStrict */, conventionally the first line of the .ts file; it applies to the whole file.

This chapter runs the same class both ways.

The faithful conversion

This file has no TjsStrict line, so nothing is checked and nothing changes behaviour.

// Classes with typed methods. The converter keeps the class, its methods and
// the parameter types. Without TjsStrict, none of them is checked.

class Calculator {
  private value: number = 0

  add(n: number) {
    this.value += n
    return this
  }

  multiply(n: number) {
    this.value *= n
    return this
  }

  getResult() {
    return this.value
  }

  reset() {
    this.value = 0
  }
}

// Use the class
const calc = new Calculator()
const result = calc.add(5).multiply(3).add(10).getResult()
console.log('5 * 3 + 10 =', result)

// Chain of operations
calc.reset()
console.log('After reset:', calc.getResult())
console.log('(2 + 3) * 4 =', calc.add(2).add(3).multiply(4).getResult())

It prints 25, 0 and 20, as the compiled TypeScript would.

Calling a class without new

TjsStrict makes the file native TJS, and in native TJS a class is called: Calculator() does exactly what new Calculator() does and returns the same object. new on a class declared in the same file is a compile error, in a .tjs file and in a .ts file marked TjsStrict:

`new Calculator` is not allowed in TJS — a class is CALLED, so `Calculator(…)` does
exactly what `new Calculator(…)` does and returns the same object. Drop the keyword.

The reverse also fails: without TjsStrict, Calculator() throws JavaScript's own TypeError: Cannot call a class constructor Calculator without |new|. So turning on TjsStrict and removing the news is one change, not two.

Why drop it? Because once the two spellings do the same thing, new carries no information, and keeping both would mean two ways to write one operation. Without it, a class is an ordinary function as far as its callers know: you can pass it where a factory is expected (names.map(Tag) builds one Tag per name) and swap a class for a factory function, or back, without editing every call site. TJS only does this for the class keyword, where a call without new had no meaning in JavaScript (it throws). Built-ins such as Boolean and Number, and old-style constructor functions, mean something different when called, so they are left alone. Classes in the TJS guide has the full rule.

The rule also covers a class constructing itself in its own static initializer: static zero = Calculator() inside class Calculator works, and Calculator.zero is a Calculator.

The same class, with TjsStrict

/* @tjs TjsStrict */

// In a real module: import { isMonadicError } from 'tjs-lang/runtime'
declare function isMonadicError(value: unknown): value is Error

class Calculator {
  private value: number = 0

  add(n: number) {
    this.value += n
    return this
  }

  getResult(): number {
    return this.value
  }
}

// A class is called, not constructed
// @ts-expect-error: a TJS class is called
const calc: Calculator = Calculator()
console.log('2 + 3 =', calc.add(2).add(3).getResult())

// A wrong argument is refused when the method is called
const bad: Calculator | Error = calc.add('5' as any)
if (isMonadicError(bad)) {
  console.log('add("5") is an error:', bad.message)
}
console.log('value is unchanged:', calc.getResult())

// A chain stops being safe: the second call is made on the error
try {
  calc.add('5' as any).add(1)
} catch (e) {
  console.log('chain:', (e as Error).name)
}

// `private` is now a real JavaScript private field
console.log('value from outside:', (calc as any).value, calc['value'])
console.log('instanceof still works:', calc instanceof Calculator)

It prints:

2 + 3 = 5
add("5") is an error: Expected number for '<source>:5:Calculator.add.n', got string
value is unchanged: 5
chain: TypeError
value from outside: undefined undefined
instanceof still works: true

What tsc makes of a TjsStrict file

The comment is invisible to tsc, but calling a class is not. tsc reports Value of type 'typeof Calculator' is not callable on Calculator(), and then types calc as any, so it checks nothing that uses it. TJS has no language server to take over, so if you still run tsc (or rely on your editor's TypeScript support), mark each call and give the result its type:

// @ts-expect-error: a TJS class is called
const calc: Calculator = Calculator()

That is what the examples on this page do, and with it tsc accepts them.

The second gap is return types. add is declared to return this, but under TjsStrict it can also return a MonadicError, and tsc cannot know that. Where a call may receive outside data, type the result as a union, as the example does with const bad: Calculator | Error. tsc then makes you check it before using it as a Calculator.

Subclasses, instanceof, getters and setters

A converted class is still a JavaScript class. Apart from being called without new, extends, super, instanceof, getters, setters and static members work as they do after tsc.

/* @tjs TjsStrict */

// In a real module: import { isMonadicError } from 'tjs-lang/runtime'
declare function isMonadicError(value: unknown): value is Error

class Shape {
  constructor(public name: string) {}

  area(): number {
    return 0
  }

  describe(): string {
    return `${this.name}: ${this.area().toFixed(2)}`
  }
}

class Circle extends Shape {
  private r: number

  constructor(radius: number) {
    super('circle')
    this.r = radius
  }

  get radius(): number {
    return this.r
  }

  set radius(value: number) {
    this.r = value
  }

  static get sides(): number {
    return 0
  }

  area(): number {
    return Math.PI * this.r ** 2
  }
}

// @ts-expect-error: a TJS class is called
const c: Circle = Circle(2)
console.log(c.describe())
console.log('instanceof Circle:', c instanceof Circle)
console.log('instanceof Shape:', c instanceof Shape)
console.log('static getter:', Circle.sides)

// The constructor's parameters are checked like a method's
// @ts-expect-error: a TJS class is called
const bad: Circle | Error = Circle('big' as any)
if (isMonadicError(bad)) {
  console.log('Circle("big") is an error:', bad.message)
}

// A setter is not checked: the bad write goes through
c.radius = 'x' as any
console.log('radius after a bad write:', c.radius)
console.log(c.describe())

It prints:

circle: 12.57
instanceof Circle: true
instanceof Shape: true
static getter: 0
Circle("big") is an error: Expected number for '<source>:17:Circle.constructor.radius', got string
radius after a bad write: x
circle: NaN

Classes from other files

The no-new rule is about classes declared in your own file. For a class you import:

When you are ready to leave TypeScript for a file, graduating it (the graduate function in tjs-lang/lang, which turns a converted file into native TJS) removes the conversion annotation and drops the new from classes declared in that file for you; see Converting a TypeScript Project.