Type-safe & expressive · compiles to TypeScript

Havran

The clean, type-safe language that compiles to the smallest, fastest JavaScript — and reads 100% of your TypeScript.

No === weirdness. No null surprises. No runtime bloat.

Havran
data class User(
  val name: String,
  val email: String?,
  var source: String = "unknown"
)

fun greet(u: User): String {
  val inbox = u.email ?: "no email on file"
  return "Hi ${u.name} — ${inbox}"
}
TypeScript
interface User {
  readonly name: string
  readonly email: string | undefined
  source: string
}

function greet(u: User): string {
  const inbox = u.email ?? "no email on file"
  return `Hi ${u.name} — ${inbox}`
}
Why Havran

Type-safe by design. Lean like hand-written JS.

Havran is designed around one promise: the clarity of a modern, expressive language with the smallest, most interoperable output a transpiler can produce.

Smallest possible output

Tiered lowering avoids JS classes until they're truly needed. Data classes erase to plain objects; enums to string unions. You ship bytes, not boilerplate.

100% TypeScript interop

Havran reads every type from your existing TypeScript and JavaScript — libraries included — and emits code any TS toolchain consumes natively.

Real null safety

One nullable model: `String?` lowers to `string | undefined`, `?.` and `?:` just work. The compiler stops null bugs before they ship.

No JavaScript weirdness

Comparing two different types is a compile error. `==` means value equality. No truthy coercion, no `===` to remember, no `NaN` surprises.

Modern, expressive ergonomics

`when` expressions, data & sealed classes, extension and scope functions, ranges, and string interpolation — the expressive syntax you already love, on the web.

Maximal performance

Iteration emits indexed `for` loops, numbers stay `number`, and the runtime is side-effect-free and fully tree-shakeable. Fast by default.

Showcase

Havran on the left. The TypeScript it generates on the right.

Every snippet, table, and note below comes straight from the language brief — the real lowering, not a marketing mock-up. Scan the fold, then expand for the full tour.

01

Mutability

HVTSNotes
varletVariable
valconstValue
02

Nullability

Havran uses only null, which is transpiled into JavaScript as undefined. For backward compatibility, you can use @JsNull null.

However, nullability comparisons are done using != null in JS, which includes both undefined and null.

HVTS
nullundefined
@JsNull nullnull
val x: String?const x: string | undefined
x?.yx?.y
x!!.yx!.y
x ?: "other"x || other
03

Comparisons

Havran resolves every weird comparison from JavaScript as a compile error. See JS weirdness

TL;DR: you cannot compare two different types together.

HVTSNotes
x != nullx != nullChecks for JS undefined & JS null
myObj == myObjvalueEquals(myObj, myObj)Data classes use valueEquals(); other classes use hashCode() comparison
"" == 5Compile errorBanned

Backward compatibility

For backward compatibility with existing code, you can always use the .jsTruthy property on any type.

Havran
val x = ""
val y = arrayOf() == true // compile error
if (x) {} // compile error
if (x.jsTruthy) // escape hatch, only for backward compatibility
TypeScript
const x = ""
if (x) {} // 'jsTruthy' lowers directly into this
04

Visibility

A unified visibility model across top-level and class-level declarations.

HVTSNotes
privateprivate (default)Visible only inside the declaring class or file
public (default)exportOnly for backward compatibility with existing code
Havran
class MyClass {
  fun myFun() {}
}
private class PrivateClass {
  private fun myFun() {}
}
TypeScript
export class MyClass {
  myFun(): void {
  }
}

class PrivateClass {
  private myFun(): void {
  }
}
05

Types

  • All types are PascalCased.
  • dynamic is not a type but a keyword that tells the compiler to skip all nullability and mutability checks. It transpiles into TS any and can be used to interact with plain JS code, such as analytics-tracking snippets.
  • Any is the supertype of all types and transpiles into TS unknown.
  • null is not a type; use the ? mark instead.
  • Long is transpiled into number by default for better performance, since JS bigint is unnecessary for day-to-day use. However, you can configure it globally to transpile into bigint, or use the @JsType(BigInt) annotation per individual use case.

Primitives

HV TypeBuilderTSNotes
Byte0x7F / 127number8-bit; width checked at compile time only (Byte = 200 is a compile error)
Short30000number16-bit; width checked at compile time only
Int1_000_000number32-bit; safe up to 2^53. Underscores allowed in literals
Long1_000Lnumber (default) / bigintL suffix. Flip per-project via havran.json "longLowering", or per-decl @JsType(BigInt) / @JsType(Number). Literal > 2^53 in number-mode warns
Float0.07fnumberf suffix is parsed and discarded; JS has no Float32. Float vs Double is compile-time only
Double3.14159numberDefault for floating-point literals. No NaN/Infinity in pure HV (throws ArithmeticException)
BigIntegerBigInteger("...")bigintAlways bigint; no config knob, @JsType does not apply. String literal folds to a …n literal
BigDecimalBigDecimal("19.99")BigDecimal (runtime class)The only number type with a runtime class (@havran/runtime); tree-shaken when unused. a + ba.plus(b)
Numbernumber | bigint | BigDecimalAbstract supertype; erased TS union. Cannot be instantiated directly (use as a bound)
Booleantrue / falsebooleanNo truthy coercion — if (n) on a non-Boolean is a compile error
Char'A'string (length 1)Single-quote literal; emitted as a double-quoted string. .code, .isDigit, etc.
String"text" / """…"""stringInterpolation $var / ${expr}; triple-quote multiline
UnitvoidDefault return type when omitted. async fun with no return → Promise<void>
NothingneverBottom type; return type of functions that throw/loop forever (fail(), TODO())
AnyunknownUniversal supertype; requires an as cast to use. Safe interop boundary
dynamicanyOpts out of type-checking & null-safety; propagates through expressions. dynamic? is redundant

Collections

HV TypeBuilderTSNotes
List<T>listOf(1, 2, 3)ReadonlyArray<T>Immutable elements & size (T[] at runtime)
MutableList<T>mutableListOf(1, 2)T[]Mutable elements & size (addpush, …)
Array<T>arrayOf(1, 2, 3)T[]Mutable elements, immutable size
Set<T>setOf("a", "b")ReadonlySet<T>Insertion-ordered, read-only view
MutableSet<T>mutableSetOf("a")Set<T>add / delete / clear
Map<K, V>mapOf("a" to 1)ReadonlyMap<K, V>Insertion-ordered, read-only view
MutableMap<K, V>mutableMapOf("a" to 1)Map<K, V>set / delete / clear
Record<K, V>recordOf("a" to 1)Readonly<Record<K, V>>Plain object; key must be String / string-literal union (not Int)
MutableRecord<K, V>mutableRecordOf("a" to 1)Record<K, V>Plain object; JSON-friendly
WeakMap<K, V>weakMapOf(k to v)ReadonlyWeakMap<K, V>Object keys only, weakly held; no iteration / .size
MutableWeakMap<K, V>mutableWeakMapOf(k to v)WeakMap<K, V>Object keys only; get / set / has / delete only
Pair<A, B>"a" to 1 / Pair(a, b)[A, B]2-tuple; to infix builds it
Triple<A, B, C>Triple(a, b, c)[A, B, C]3-tuple
Sequence<T>sequenceOf(1, 2, 3)lazy function* iteratorLazy single-pass; also .asSequence(), generateSequence { }
ByteArraybyteArrayOf(1, 2)Uint8ArrayFixed-length typed array
IntArrayintArrayOf(1, 2)Int32ArrayFixed-length typed array
LongArraylongArrayOf(1L)BigInt64ArrayFixed-length typed array
FloatArrayfloatArrayOf(1.0f)Float32ArrayFixed-length typed array
DoubleArraydoubleArrayOf(1.0)Float64ArrayFixed-length typed array

Empty builders need a type from context: emptyList<T>()[], mapOf()new Map(), setOf()new Set().

buildList { add(x) } / buildMap / buildSet / buildString construct via an IIFE that returns the read-only view.

Iteration over arrays emits indexed for loops (not .forEach()); Set / Map / Record / custom Iterable keep for...of (Object.entries for Record).

Aliases (TS types)

Supports all TypeScript advanced generics, including Partial<T>, Readonly<T>, Pick<T>, Omit<T>, mapped types, template literals, infer, and conditional types.

Havran
typealias X = (val x: String, val y: Int)
typealias Y = String | Double | (val x: String?, var y: dynamic) | (enum Color(RED, GREEN))

typealias Omit<T, K : keys *> = Pick<T, Exclude<keys T, K>>
typealias Readonly<T> = (val [P in keys T]: T[P])

// Auto infer
typealias Awaited<T> = when (T) {
	is Any? -> T
	T is (then(onfulfilled: F, ...): dynamic, ...) -> 
	  if (F is (value: V, ...) -> dynamic) Awaited<V> else Nothing
	else -> T
}
TypeScript
type X = { readonly x: string, readonly y: number };
type Y = string | number | { readonly x?: string, y: any } | "RED" | "GREEN";

type Readonly<T> = { readonly [P in keyof T]: T[P] };
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

type Awaited<T> = T extends null | undefined ? T :
  T extends object & { then(onfulfilled: infer F, ...args: infer _): any; } ?
    F extends ((value: infer V, ...args: infer _) => any) ?
      Awaited<V> :
      never :
    T;

Anonymous Objects, Types, Enums

You can declare object shapes using classes, or anonymous objects.

Types are auto-derived from passing values.

Schema + Values

For one-time use, you can declare an anonymous object with properties and values directly, without needing to define a class.

Havran
val x = object {
  val name = "Ben"
  var country = "Slovakia"
  val address = object {
    val line1 = "line1"
    //...
  }
}
TypeScript
const x = {
  name: "Ben",
  country: "Slovakia",
  address: {
    line1: "line1"
  }
}
Anonymous Type + Instance

Anonymous types are worth using for one-time objects; otherwise prefer using data classes for better readability and reusability.

Havran
  val x: (val name: String, val country: String, val address: (val line1: String)) 
    = ("Ben", "Slovakia", ("line1"))
TypeScript
const x: { name: string; country: string; address: { line1: string } } = {
  name: "Ben",
  country: "Slovakia",
  address: {
    line1: "line1"
  }
}
Anonymous object of Interfaces

Anonymous objects can implement interfaces

Havran
interface MyInterface {
  val name: String
  val country: String
}

val x = object: MyInterface {
  override var name = "Ben"
  override val country = "Slovakia"
  val address = object {
    val line1 = "line1"
    //...
  }
}
06

Functions

  • Declared with the fun keyword.
  • The default return type, when not defined, is Unit (TS void).
  • Can be declared as a one-liner (the return type can be omitted to let the compiler infer it).
  • Acts as a statement.
Havran
fun oneLiner() = "hello there"
fun determine(x: Any): String = when(x) {
  is Double -> "It's a double!"
  is String -> "It's a string!"
  else -> "Not found"
} 
open fun canBeOverridden()
override fun canBeOverridden()

private fun canBeOverridden()
TypeScript
export function oneLiner(): string {
  return "hello there"
}

Functions Overloading

Havran
fun doSomething(x: String, y: String)
fun doSomething(x: Int, y: String)
fun doSomething(x: Chat, y: Int)
TypeScript
function doSomething(
  kind: { x: string; y: string } 
    | { x: number; y: string } 
    | { x: string; y: number }
): void {}

Extension functions

Can be called as if they were member functions of the receiver type, but are defined outside of it. The receiver is accessed via this inside the function.

Havran
fun String.slugify(): String {...}

"Hello World".slugify()

Operator functions (Overriding operators)

Can be called using operator syntax, but are defined as regular functions with the operator modifier. The parameters and return type depend on the operator being overridden.

HV OperatorPreview
plusa + b
minusa - b
timesa * b
diva / b
rema % b
unaryPlus+a
unaryMinus-a
not!a
inc++a / a++
dec--a / a--
geta[i]
seta[i] = v
invokea(x, y)
containsa in b
rangeToa..b
equalsa == b
compareToa < b
plusAssigna += b
minusAssigna -= b
timesAssigna *= b
divAssigna /= b
remAssigna %= b
iteratorfor (x in a)
getValuex (requires delegation: val x by d)
setValuex = y (requires delegation: val x by d)
Havran
var count by signal(0)
class Signal<T> {
    operator fun getValue(...): T {...}
    operator fun setValue(..., value: T) {...}
}
count // getValue
count++ // setValue
TypeScript
// React
const [_count, setCount] = useState(0)
_count // get
setCount(_count + 1) // set

// Angular
const count = signal(0)
count() // get
count.set(count() + 1) // set

Test functions

Havran
fun `test scenario - goal achievement`() {...}
`test scenario - goal achievement`()

Async, Suspend functions

Both return a Promise<T>.

Suspended functions inserts auto-await at all promise-like functions, while async functions require explicit await when calling.

Havran
data class Resp(val id: Long, val email: String?)
async 

async fun doAsync(): Resp { // Returns Promise<Resp>
  val a = await callApiA()
  val b = await callApiB()
  return await callApiC()
}

// auto-awaited all Promise functions (async, suspend)
suspend fun doAsync(): Resp { // Returns Promise<Resp>
  val a = callApiA()
  val b = callApiB()
  return callApiC()
}

Inline functions

Inserts the body at each call site. Useful for small functions, higher-order functions, and to avoid unnecessary lambdas / function calls.

Havran
inline fun measure(block: () -> Unit) {
    log.info("start")
    block()
    log.info("end")
}

measure {
    log.info("hello")
}
TypeScript
// Generated output
log.info("start")
log.info("hello")
log.info("end")

Infix functions

Regular functions that can be called without dots or parentheses for better readability.

Havran
infix fun <A, B> A.to(that: B): Pair<A, B>

val pair = "a" to 123
val pair = "a".to(123)

Nested functions

Havran
fun myFun() {
  fun nestedFun() {}
  nestedFun() // ok
}
// nestedFun() // compile error, only visible inside myFun()

Generics functions

Havran
fun <T> doSmth(x: T): T? {...}
fun <out T> doSmth(x: T): T? {...} // in-out, compiler-only check
fun <T: MyClass> doSmth(x: T): T {...} // T extends MyClass
fun <T> doSmth(x: T): T where T : MyClass, T: MyOtherClass {...} 
inline fun <reified T> doSmth(x: T): T {
  if (T is MyClass) {...}
}
TypeScript
function doSmth<T>(x: T): T | undefined {/*...*/
}

function doSmth<T>(x: T): T | undefined {/*...*/
}

function doSmth<T extends MyClass>(x: T): T {/*...*/
}

function doSmth<
  T extends MyClass, 
  T extends MyOtherClass
>(x: T): T {/*...*/}

// inline + reified is evaluated in the call place

Scope functions

Provide a temporary scope for an object, allowing you to perform multiple operations on it without repeating its name.

The most common ones are apply (returns the object) and with (returns the lambda result).

Havran
data class User(val id: Long, val name: String, val email: String) { val x: String = "something" }
val u = User(..).apply {
  x = "other"
}
with (u) {
  name = "Ben"
  email = "[email protected]"
}
TypeScript
const u: User = {...}
// apply
u.x = "other"

// With
u.name = "Ben"
u.email = "[email protected]"
07

Classes

Constructor overloading
Havran
class MyClass(val x: String, ...) {
  // additional constructor
  constructor(val y: String, val other: Int) : super(other, ...)
}
Equality
  • Done by hashCode() for non data classes.
  • Done by value equals() for data classes.
Instantiation
  • No new required
Havran
class MyClass(val x: String)
const x = MyClass("hello")

Class (standard)

  • Transpiles into JavaScript class
  • Can be inherited when marked as open class
Havran
  open class ParentClass(
    val x: String, 
    var y: Int
  ) // 'open' to allow inheritance 
  
  class MyClass(
    val x: String, 
    var y: Int = 45
  ) : ParentClass(x, y)
TypeScript
export class ParentClass {
  constructor(readonly x: string, y: number) {
  }
}

export class MyClass extends ParentClass {
  x: string
  y: number = 45

  constructor(readonly x: string, y: number) {
    super(x, y)
    this.x = x
    this.y = y
  }
}

Data Class

  • Compared by value, not by hash code.
  • Cannot be inherited.
  • Use it for API requests.
  • Transpiles into a TS interface where possible, via tiered lowering.
  • Offers a .copy() function to create a shallow copy.
Havran
data class User(val name: String, val age: Int)

val u = User("Ann", 30)
val older = u.copy(age = 31)
TypeScript
interface User {
  readonly name: string
  readonly age: number
}

const u: User = { name: "Ann", age: 30 }
const older: User = { ...u, age: 31 }

Sealed Class

  • Fixed set of subtypes, all declared in the same file
  • Use the class form when the parent carries shared state/behavior — emits an abstract class + per-subtype subclass; when still lowers to an exhaustive switch on a kind discriminator
Havran
sealed class Shape {
  abstract fun area(): Double
}
data class Circle(val radius: Double) : Shape() {
  override fun area(): Double = 3.14 * radius * radius
}
data class Rectangle(val width: Double, val height: Double) : Shape() {
  override fun area(): Double = width * height
}
TypeScript
export abstract class Shape {
  abstract area(): number
}
export class Circle extends Shape {
  constructor(readonly radius: number) { super() }
  override area(): number { return 3.14 * this.radius * this.radius }
}
export class Rectangle extends Shape {
  constructor(readonly width: number, readonly height: number) { super() }
  override area(): number { return this.width * this.height }
}

Interface

Declares abstract members an implementor must provide; a class implements it via :

Methods can have default bodies — these lower to standalone helper functions (no per-class duplication)

Havran
interface Named {
  val name: String
  fun greet(): String = "Hello, $name"   // default method
}

class Employee(override val name: String) : Named
TypeScript
interface Named {
  readonly name: string
  greet(): string
}
const Named_greet = (self: Named): string => `Hello, ${self.name}`

class Employee implements Named {
  constructor(public readonly name: string) {}
  greet(): string { return Named_greet(this) }
}
Merging interfaces

Added for TypeScript interoperability, where you can produce a single interface from multiple declarations with the same name.

Done by the @Merge annotation

Havran
@Merge
interface User {
  val name: String
  val email: String
}

@Merge
interface User {
  val age: Int
}

// Becomes
interface User {
  val name: String
  val email: String
  val age: Int
}

Sealed Interface

Default for pure-data hierarchies — lowers to a discriminated union (kind tag), zero runtime classes, plus a factory const

when is exhaustive — no else needed; adding a subtype turns non-exhaustive whens into compile errors

Havran
sealed interface Payment
data class Card(val last4: String) : Payment
data class Cash(val amount: Double) : Payment

fun describe(p: Payment): String = when (p) {
  is Card -> "card ${p.last4}"
  is Cash -> "cash ${p.amount}"
}
TypeScript
export type Payment =
  | { kind: "Card"; last4: string }
  | { kind: "Cash"; amount: number }

export const Payment = {
  Card: (last4: string): Payment => ({ kind: "Card", last4 }),
  Cash: (amount: number): Payment => ({ kind: "Cash", amount }),
}

function describe(p: Payment): string {
  switch (p.kind) {
    case "Card": return `card ${p.last4}`
    case "Cash": return `cash ${p.amount}`
  }
}

Value Class

A compiler-only feature for strict type checking.

Havran
value class UserId(id: Long)
value class ProfileId(id: Long)

// UserId cannot be passed into ProfileId, even though they are both Longs
fun findById(id: UserId, profileId: ProfileId) {
}
TypeScript
function findById(id: number, profileId: number) {
}

Object Class

Singleton classes.

Can be used to hold utility functions, constants, or represent a single instance of a type.

Havran
object MyObject {
  val x: Float = 5f
  fun myFun() = "hello"
}
// MyObject() // compile error
TypeScript
export const MyObject = {
  x: 5,
  myFun: () => "hello"
}

Companion (TS Static), Init blocks

All static members go into a companion object block, which lowers to static members on the class. The static keyword is not used in Havran.

Havran
class MyClass {
  companion object {
    const val TAG = "debug"
    fun myFun() {...}
    init {
      log.info("Something")
    }
  }
  
  init {
    log.info("Initialized")
  }
}
TypeScript
class MyClass {
  static readonly TAG = 'debug'
  static {
    log.info("Something")
  }

  static myFun() {...}

  constructor() {
    log.info('Initialized')
  }
}

Enum Class

Tiered lowering based on the applied features.

Never transpiled into a TS enum, but users can still access its instances as usual.

Havran
// regular enum
enum class Color { RED, GREEN, BLUE }

// type enum, for one-time use
val x: (enum Color { RED, GREEN, BLUE }) = Color.RED

// With parameters, default values
enum class ColorHex(hex: String = "#000") { 
  RED("#asf511"), 
  GREEN("#asf511"), 
  BLUE("#asf511"); 
}

// With functions, companion objects, extensions etc
enum class Difficulty {
  EASY,
  MEDIUM,
  HARD;
  
  fun myFun() {
    // do something useful
  }
}
Difficulty.EASY.myFun()
TypeScript
type Color = "RED" | "GREEN" | "BLUE"

const x: "RED" | "GREEN" | "BLUE" = "RED"

export const ColorHex = {...}

export const Difficulty = {...}
// + helpers, or a class

Dynamic Class

Can contain unknown members.

All known members are handled with normal type-checking, while unknown members are allowed and treated as dynamic (TS any), with no compile errors.

Havran
dynamic class MyClass(
  val x: String, 
  val y: Other
)
val obj = MyClass()

obj.x = 45 // compile error, since 'x' is a String
obj.nonExisting.access()?.nullable?.["hello"] // ok, since 'nonExisting' is unknown
08

Exceptions

Havran ships a familiar, modern hierarchy rooted at Throwable, mapped onto the JS Error chain so that instanceof and .message / .stack work natively.

ExceptionExtendsWhen to use
ThrowableRoot of the hierarchy; carries message / cause. Rarely thrown directly.
ExceptionThrowableBase for recoverable conditions; the broad catch (e: Exception) target.
ErrorThrowableHard failures (out-of-memory, stack overflow). Don't catch in user code.
RuntimeExceptionExceptionGeneric unchecked throw when no specific subtype fits.
IllegalArgumentExceptionRuntimeExceptionA function got an invalid argument. Thrown by require(...).
IllegalStateExceptionRuntimeExceptionObject is in a bad state for the call. Thrown by check(...) / error(...).
IndexOutOfBoundsExceptionRuntimeExceptionBad list/string index (list[100], mut[i] = v). Safe variant: getOrNull.
NullPointerExceptionRuntimeExceptionMember access on null without ?. (rare — null-safety prevents most).
ArithmeticExceptionRuntimeExceptionMath that would yield NaN / Infinity (1.0 / 0.0, sqrt(-1.0)).
NumberFormatExceptionIllegalArgumentExceptionInvalid string→number conversion (prefer toIntOrNull() etc. which return null).
UnsupportedOperationExceptionRuntimeExceptionOperation not supported in this context.
ClassCastExceptionRuntimeExceptionA runtime as cast failed.
ConcurrentModificationExceptionRuntimeExceptionCollection mutated during iteration.
NoSuchElementExceptionRuntimeExceptionRequested a missing element (first() / iterator next() on empty).
UninitializedPropertyAccessExceptionRuntimeExceptionRead a lateinit property before initialization.
CancellationExceptionRuntimeExceptionAwaiting a cancelled async task. From @havran/async (import required).
IOExceptionExceptionI/O failure (file / network).
NotImplementedErrorErrorReached a TODO() placeholder at runtime.
AssertionErrorErrorAn assertion failed.
OutOfMemoryErrorErrorRuntime ran out of memory (not user-thrown).

Notes

  • TODO() throws NotImplementedError; fail("msg") is shorthand for a throw that returns Nothing.
  • CancellationException lives in @havran/async (not auto-prelude) — needs import { CancellationException } from "@havran/async".
  • throw only accepts Throwable subtypes — throw "string" / throw 5 are compile errors.
Havran
class MyClass(val smth: String?)
val x = MyClass(null) 
val r = x.smth ?: throw RuntimeException("Parameter is required")
TypeScript
// requires runtime import
const x = new MyClass(undefined)
const r = x.required ?? throw new RuntimeException("Parameter is required")

Try-catch-finally

Havran offers the familiar try-catch-finally syntax, with some simplifications for common patterns.

Havran
try {
  val r = x.required ?: throw RuntimeException()
} catch (e: RuntimeException) {
  
} finally {}

// simplified / one-line only
val a = await callApi() catch(e: MyApiException) {...}
val b = await callApi() catch(e: MyApiException) {...} finally {...}
val c = await callApi() finally {...}
TypeScript
// simplified / one-line only
let a
try {
  a = await callApi()
} catch (e: MyApiException) {}
09

Statements

Every expression can be used as a statement, so there is no separate statement syntax. This allows for more concise code and better readability.

If / When (switch)

  • The (condition) ? true : false ternary is replaced with if (condition) true else false.
  • switch was renamed to when for better readability and more options.
Havran
val x = if (condition) "true" else "false"
val y: String = when (something) {
  is String -> "is string"
  is Int, in 0..5 -> "within a 0 - 5 range"
  else -> "unknown"
}

For / While

Havran
for (i in 2..10) log.info("It's $i")

while (condition) 
do {
  //...
} while (condition) 

// Helper function for `for (i in 0..10)`
repeat (10) { log.info("It's $it") }

fun validate() = try {
  //...  
} catch (e: Exception) {}

fun check(x: String?) = if (x != null) "not-null" else "other" 
fun doIt(val x: Int = 50) = for (i in 0..x) {/*...*/}
10

Web APIs

We aim to provide a comprehensive, ergonomic, and consistent wrapper over the most commonly used Web APIs, with a focus on modern best practices and patterns. The APIs are designed to be easy to use and integrate seamlessly with the rest of the language features.

Datetime

Immutable Temporal-backed types — JS Date is hidden and auto-converted at the boundary. Also LocalTime, LocalDateTime, Period, DateTimeFormatter, DayOfWeek, Month.

Havran
val now: Instant = Clock.System.now()
val date: LocalDate = LocalDate.parse("2026-05-31")
val inNy: ZonedDateTime = now.atZone(TimeZone.of("America/New_York"))
val later: Instant = now + 5.minutes   // Duration ext: .millis / .seconds / .minutes / .hours / .days
val gap: Duration = later - now
TypeScript
const now = Temporal.Now.instant()
const date = Temporal.PlainDate.from("2026-05-31")
const inNy = now.toZonedDateTimeISO("America/New_York")
const later = now.add({ minutes: 5 })  // 5.minutes → { minutes: 5 }
const gap = later.since(now)

Storage

  • A unified storage API.
  • Areas: local · session · memory · cookie · indexed · cache. Each has set<T> / get<T> / remove / clear / has / key / getAll / size.
Havran
// auto stringifies
Storage.local.set("user", user)

// auto parses, or returns null
val u = Storage.local.get<User>("user")
Storage.session.has("token")
Storage.local.remove("user")
Storage.memory.clear()
TypeScript
localStorage.setItem("user", JSON.stringify(user))
const u = JSON.parse(localStorage.getItem("user"))  // Storage.memory is an in-process Map

Math

  • sqrt, cbrt, pow, hypot, sin/cos/tan/asin/acos/atan/atan2, sinh/cosh/tanh, exp, ln, log10, log2, log, floor, ceil, round, truncate, abs, min, max, sign, PI, E.
  • Plus Random from havran.random.
Havran
val h = hypot(a, b)
val r = Random.Default.nextInt(100)
TypeScript
const h = Math.hypot(a, b)
const r = Math.floor(Math.random() * 100)  // Random.Default
  • sqrt(-1.0) / 1.0 / 0.0 throw ArithmeticException.

Text

  • Regex, RegexOption, MatchResult, MatchGroup, StringBuilder. Regex.
Havran
val re = Regex("\\d+")
re.containsMatchIn("abc123")   // true
re.findAll("a1 b2")
re.replace("x1", "_")
"a,b,c".split(",")
TypeScript
const re = /\d+/               // literal folds; dynamic → new RegExp("\\d+")
/\d+/.test("abc123")

URL

Havran
val u = Url("https://ben.dev/docs?tab=api#top")
u.host                          // "ben.dev"
u.pathname                      // "/docs"
u.searchParams.get("tab")       // "api"
val built = Url.build(host = "ben.dev", path = "/docs", query = mapOf("tab" to "api"))
TypeScript
const u = new URL("https://example.com/docs?tab=api#top")
u.host; u.pathname; u.searchParams.get("tab")

UrlSearchParams: get / getAll / set / append / delete / has / keys.

Timers

All schedulers return a Disposable (.dispose() cancels). Plus measureTimeMillis { } and measure(name) { }.

Havran
val t = timeout(5.seconds) { 
  log.info("fired") 
}   // t.dispose() to cancel

val tick = interval(5.minutes) { 
  poll() 
}
await sleep(2_000)
requestAnimationFrame { ts -> render(ts) }
TypeScript
const t = { dispose: () => clearTimeout(setTimeout(() => log.info("fired"), 5000)) }
const tick = { dispose: () => clearInterval(setInterval(() => poll(), 300000)) }
await new Promise(r => setTimeout(r, 2000))
requestAnimationFrame((ts) => render(ts))

Encoding, Crypto

Base64 (encode / encodeBytes / decode / decodeToString, Base64.UrlSafe), UriCodec, TextCodec, Hex.

Havran
Base64.encode("héllo")
Base64.decodeToString(s)
UriCodec.encode("a b")
TextCodec.encode("hi")
TypeScript
Base64.encode("héllo")          // btoa(...) over TextEncoder bytes
encodeURIComponent("a b")
new TextEncoder().encode("hi")

Uuid, SecureRandom, Hash (sha1/256/384/512), Hmac (sha256/512), HashResult. Hashing is async.

Havran
import { Uuid, SecureRandom, Hash } from "havran.crypto"
val id = Uuid.random()
val token = SecureRandom().nextBytes(16)
val digest = Hash.sha256("hello").toHex()   // async — await / call from async fun
TypeScript
const id = new Uuid(crypto.randomUUID())
const token = new Uint8Array(16); crypto.getRandomValues(token)
const digest = (await crypto.subtle.digest("SHA-256", new TextEncoder().encode("hello"))) // → HashResult.toHex()

DOM

Window / Document lower to native window / document (the import is type-only). Document.querySelector / getElementById / createElement / body / title; Window.alert / location / innerWidth.

Havran
import { Window, Document } from "@havran/web"
val app = Document.querySelector("#app")
Window.alert("hi")
val w = Window.innerWidth
TypeScript
const app = document.querySelector("#app")
window.alert("hi")
const w = window.innerWidth
Events (listeners)

on* aliases lower directly to addEventListener; observer / drag-drop extensions return a Disposable.

Havran
button.onClick { 
  log.info("x=${it.clientX}") 
}

input.onKeyDown { 
  if (it.key == "Enter") submit() 
}

el.on("customEvent") { 
  handle(it) 
}

val d = el.observeIntersection(threshold = 0.5) { entry -> 
  if (entry.isIntersecting) show() 
}

el.onDrop { files, ev -> 
  for (f in files) upload(f) 
}
TypeScript
button.addEventListener("click", (it) => log.info(`x=${it.clientX}`))
input.addEventListener("keydown", (it) => { if (it.key === "Enter") submit() })
el.addEventListener("customEvent", (it) => handle(it))
const d = extensionElementObserveIntersection(el, 0.5, "0px", (entry) => { if (entry.isIntersecting) show() })
extensionElementOnDrop(el, (files, ev) => { for (const f of files) upload(f) })

Also observeResize, Node.observeMutations, onDragOver / onDragEnter / onDragLeave.

11

Imports / Exports

Syntax unchanged; import type was removed in favor of automatic determination (it always uses import type when possible).

Havran
import { MyClass } from "@havran/runtime"

Barrel exports (index.ts)

Re-exporting types through a barrel index.ts can be achieved using the @Export annotation.

Havran
@Export
class MyClass {}

fun myFun() {}
TypeScript
// demo/main.ts
export class MyClass {}
export function myFun(): void {}

// index.ts
export { MyClass } from "./demo/main"

Namespaces

Only for backward compatibility; use the @Namespace annotation.

Havran
@Namespace("App")
class MyClass
TypeScript
declare namespace "App" {
  class MyClass {}
}

Export Default

default is no longer a keyword; to set a default export, use the @Default annotation.

Havran
@Default
fun main() {}
fun secondary() {}
TypeScript
export default function main(): void {}
export function secondary(): void {}

Declare Global

global is no longer a keyword; to declare a global, use the @Global annotation.

Havran
@Global
interface Config {}
TypeScript
declare global {
  export interface Config {}
}
FAQ

Questions, answered

  • Havran is a web programming language that transpiles into clean, modern and very restrictive TypeScript and JavaScript without any JS weirdness with 100% mutual interoperability with your existing TS/JS codebase. You write expressive, type-safe code; Havran emits the smallest, fastest output a compiler can produce.

  • Havran is a strict superset of intent, not syntax: it reads 100% of your existing TypeScript and JavaScript — types, libraries, everything — and the code it generates is consumed natively by any TypeScript toolchain. You can adopt it file-by-file.

  • TypeScript is still JavaScript but with types. Havran is a new web language that removes the sharp edges: no `===`, no truthy coercion, comparing two different types is a compile error, and nullability has a single clear model. You also get data classes, sealed types, `when` expressions, and scope functions — with zero runtime cost where possible. See the features list for comparison.

  • No. Havran aspires to produce the lowest possible JS bundle with the maximal JS performance. Tiered lowering avoids emitting JavaScript classes until a feature truly needs them — data classes become plain objects, enums become string unions, iteration becomes indexed `for` loops, and the runtime is side-effect-free and tree-shakeable. See the comparison list to convince yourself.

  • Yes. Because the output is ordinary TypeScript, Havran works with React, Angular, Vue, Svelte, Solid, Astro, Next.js, Nest.js and plain Node — no special bindings or runtime required.

  • Havran is in active development in the late alpha phase. For now, we offer only live playground where you can experiment with it. For now, we offer only a live playground where you can experiment with the language. Since creating a programming language is not an easy task even in the age of AI, we kindly ask you for donations to let us know if we should continue or not. The language and compiler are evolving quickly — join the community below to follow along, try it out, and shape where it goes next.

  • It's not Kotlin/JS at all. We took only the Kotlin-like syntax and merged it with Kotlin, TypeScript, and our features that web development needs. Havran is built with native support for JS/TS and the existing web frameworks ecosystem - nothing related to Java or Gradle.

  • No. Developers can gradually switch codebases to Havran, without needing to rewrite everything at once. Havran is designed to be a seamless addition to your existing TypeScript codebase, allowing you to adopt it incrementally and enjoy its benefits without a complete overhaul.

  • We offer 100% interoperability with existing TypeScript/JavaScript codebases. Havran is a merge of TypeScript, Kotlin and our features together. We offer unique features such as anonymous types, dynamic classes, advanced generics, built-in reflection, environment-strictness utils (browser/server), custom managed suspended context, more types, type-based and simplified Browser API and so much more. See the feature list for deep exploration.

  • For web development, you should still know JavaScript to understand the core principles. We'll continuously improve our docs to help you understand the Havran syntax and features, but having a background in Kotlin can be beneficial for grasping the language's design and features more quickly. However, it's not a strict requirement to get started with Havran.

  • We are independent software engineers with nearly two decades of software development in various technologies such as JavaScript, TypeScript, Java, C, and Kotlin. We have all delivered numerous web/server projects, and we are tired of JavaScript weirdeness with the ambitious to stop writing in JavaScript whatsoever! But for this, we kindly ask you to make a donation for us to keep the project up and running. Big thank you!

  • We are still making fundamental and optimization changes, so we will open-source it as soon as we finish the fundamentals. To make it happen, please let us know you're interested by donating to us, sign-up to a newsletter, and sharing with your software-engineer colleagues. We thank you! For now, you can see the language progress in our live playground. We are open to your feedback!

  • We don't recommend using any escape to raw JavaScript / TypeScript. However, for inevitable use cases, it is still possible, but you should prefer the strict approach in terms of types, nullability, mutability, and other principles. However, Havran offers 100% mutual interoperability with TypeScript/JavaScript from day one.

Get involved

Join the flock. Help shape Havran.

Havran is built in the open. Subscribe for updates, jump into the community, share it with a friend, and tell us what you'd build with it.

Stay in the loop

Occasional updates on releases, the roadmap, and deep dives. No spam, ever.

🔒 We store your email only to send you Havran updates. We never share it, and you can unsubscribe or ask us to delete it any time at [email protected] .

Love Havran? Sponsor it

Havran is free and built in the open. A monthly or one-time sponsorship keeps it fast, independent, and yours.

Become a sponsor →

Share Havran

Know someone tired of JavaScript footguns? Send them this page.

Tell us what you think

Got a feature request, a rough edge, or a wild idea? We genuinely want to hear it.

Give feedback →