gotlin/README.md
2026-08-28 00:02:32 +02:00

9.9 KiB

Gotlin

Gotlin is a Kotlin-flavored language implemented in Go. It compiles typed Gotlin source to Go and uses the Go toolchain for binaries, packages, and interop.

package main

fun fibonacci(value: Int): Int {
    if (value < 2) { return value }
    return fibonacci(value - 1) + fibonacci(value - 2)
}

fun main() {
    println(fibonacci(10))
}

Compiler pipeline

Lexer
  -> parser and syntax AST
  -> package resolver and structural TypeRef resolution
  -> lexical symbols, type checking, effects, and diagnostics
  -> typed HIR
  -> Go emission
  -> Go compiler

Semantic types distinguish named values, Gotlin classes, nullable values, Go pointers, functions, generics, tuples, and imported Gotlin classes. A Gotlin class remains User throughout semantic analysis; only Type-to-Go lowering chooses the *User representation.

Separately compiled Gotlin packages expose a versioned .gti.json interface containing classes, enums, signatures, generics, and inferred effects. The compiler and LSP load package interfaces before using go/types for ordinary Go dependencies.

Functions and values

fun greet(name: String): String {
    val message = "Hello " + name
    return message
}

fun doubled(value: Int) = value * 2

val is immutable and var is mutable:

val accountId = "account-1"
var attempts = 0
attempts += 1

Supported control flow includes if, else, while, for, try, catch, throw, exhaustive match, and defer.

Classes and interfaces

Gotlin classes are reference-valued by default:

interface Greeter {
    fun greet(name: String): String
}

class PrefixGreeter(val prefix: String): Greeter {
    fun greet(name: String) = prefix + name
}

fun create(): PrefixGreeter = PrefixGreeter("Hello ")

Use * only for Go pointer types:

fun handle(request: *http.Request, pool: *pgxpool.Pool) { }

Imported Gotlin classes retain reference semantics across package boundaries:

fun publish(lifecycle: platform.Lifecycle) { }

Null safety

Types are non-nullable unless marked with ?:

fun email(user: User?): String {
    if (user == null) { return "missing" }
    return user.email
}

Safe access and non-null assertions are available:

val city: String? = user?.address?.city
val required: User = optionalUser!!

The semantic analyzer smart-casts values after null checks and guard clauses.

Data classes and JSON

@jsonNaming(camelCase)
data class AccountReply(
    var accountId: String,
    var availableBalance: Double
)

Supported JSON policies are snakeCase, camelCase, pascalCase, and kebabCase.

val body = json.marshal(reply).unwrap()
val decoded = json.decode<AccountReply>(body).unwrap()

Resources can be embedded at package scope:

import embed

@embed("static/*") val assets: embed.FS

Enums and match

Enums support payloadless and payload variants:

enum PaymentResult {
    Accepted(String)
    Rejected(String)
    Pending
}

Matches are exhaustive and may return values:

fun description(result: PaymentResult) = match (result) {
    PaymentResult.Accepted(id) -> "accepted " + id
    PaymentResult.Rejected(reason) -> "rejected " + reason
    PaymentResult.Pending -> "pending"
}

Payloadless enums are represented as exact string-backed values, making them suitable for JSON and PostgreSQL columns.

Result error handling

fun parse(value: String): Result<Int, Error> = strconv.atoi(value)

fun doubled(value: String): Result<Int, Error> {
    val parsed = strconv.atoi(value)?
    return Result.Ok(parsed * 2)
}

Available operations include ?, unwrap(), unwrapOr(value), explicit destructuring, and exhaustive Result matching. Go (T, error) and error-only returns adapt to Result when required by context.

Generics and lambdas

data class Box<T>(var value: T) {
    fun get(): T = value
}

fun identity<T>(value: T): T = value

val inferred = identity(42)
val explicit = identity<String>("value")
val boxed = Box("text")

Higher-order functions use Kotlin-style function types and trailing lambdas:

fun transform<T, R>(value: T, block: (T) -> R): R = block(value)

val answer = transform(21) { value ->
    value * 2
}

The final expression is returned automatically for value lambdas. Unit lambdas execute their final expression as a statement. Explicit return is also supported.

Structured concurrency and context

Coroutine effects are inferred from direct and transitive calls:

fun poll() {
    while (isActive()) {
        receiveEvents()
        delay(1000)
    }
}

fun main() {
    runBlocking {
        launch { poll() }
    }
}

launch creates a structured Unit child. async<T> creates a typed deferred child. Scopes wait for children, propagate failures, and cancel siblings.

Available operations include runBlocking, withContext, coroutineScope, launch, async, await, delay, withTimeout, isActive, and coroutineContext.

HTTP request contexts can establish an ambient scope with an expression-bodied function:

fun handle(request: *http.Request) = withContext(request.context()) {
    service.processRequest()
}

Go calls whose first parameter is context.Context receive the ambient context automatically when that argument is omitted:

fun command(): *exec.Cmd = exec.commandContext("date")

Passing an explicit context suppresses automatic injection.

Collections and channels

val names = listOf<String>("Ada", "Linus")
val scores = mapOf<String, Int>("Ada", 10, "Linus", 8)
val channel = Channel<String>(1)

channel.send("ready")
val message = channel.read()

Collection types include List<T>, MutableList<T>, Map<K, V>, and MutableMap<K, V>.

Structural mapping

mapTo maps compatible classes, enums, collections, and nullable values:

fun response(account: AccountEntity): AccountResponse = account.mapTo()

val response = account.mapTo<AccountResponse>()

Fields are matched by name and mappings are validated recursively with path-specific compile errors.

Typed PostgreSQL DSL

SQL rows are data classes with table metadata:

@table("accounts")
data class AccountRow(
    @generated @id var id: String,
    var customerId: String,
    var balance: Double,
    var closedAt: time.Time?
)

Queries, aliases, joins, groups, and aggregates

@table("customers")
data class CustomerRow(@id var id: String, var name: String)

data class AccountSummary(
    var customerId: String,
    var customerName: String,
    var total: Double,
    var entries: Long
)

fun summaries(minimum: Double): GotlinSQLQuery = sql.from<AccountRow>()
    .alias("account")
    .leftJoin<CustomerRow>("customer") { account, customer ->
        account.customerId == customer.id
    }
    .select { account, customer ->
        AccountSummary(
            account.customerId,
            customer.name,
            sum(account.balance),
            count()
        )
    }
    .where { account, customer -> account.balance > minimum }
    .groupBy { account, customer -> listOf(account.customerId, customer.name) }
    .having { account, customer -> sum(account.balance) > minimum }
    .orderByDescending { account, customer -> sum(account.balance) }
    .limit(100)
    .offset(20)
    .build()

Join methods include join, leftJoin, and rightJoin. Aggregates include count, countDistinct, sum, avg, min, and max.

Inserts and bulk inserts

val insert = sql.insert<AccountRow>(row).build()
val bulk = sql.insertAll<AccountRow>(rows).build()

Bulk inserts create runtime-sized PostgreSQL placeholder lists and reject empty input.

Conflicts and updates

val upsert = sql.insert<AccountRow>(row)
    .onConflict { it.id }
    .doUpdate { excluded ->
        AccountRow.balance = excluded.balance
    }
    .build()

val update = sql.update<AccountRow>()
    .set { row ->
        row.balance = row.balance + amount
        row.closedAt = now()
    }
    .where { it.id == accountId }
    .returning { it }
    .build()

Write targets are checked for existence, mutability, nullability, duplicate assignment, and value compatibility.

Execution

val accounts = sql.from<AccountRow>()
    .where { it.customerId == customerId }
    .fetch(pool, ctx)
    .unwrap()

val account = sql.from<AccountRow>()
    .where { it.id == accountId }
    .single(pool, ctx)
    .unwrap()

Execution terminals are fetch, single, and iterator. They return typed Result values and use generated row scanners.

Go interop

Go packages are imported directly:

import http net.http
import pgxpool "github.com/jackc/pgx/v5/pgxpool"

Go selectors are written in lower camel case and emitted with exported Go names. Named arguments construct external Go structs:

val client = http.Client(timeout = 3 * time.second)

go/types and go/packages provide function, method, field, alias, tuple, variadic, and error-return signatures to semantic analysis and the LSP.

Build and run

go run ./cmd/gotlinc build ./examples/hello.gt
./hello

go run ./cmd/gotlinc run ./examples/hello.gt

Emit Go source:

go run ./cmd/gotlinc build -src ./examples/hello.gt -o /tmp/hello.go

Emit and consume package interfaces:

gotlinc build -src \
  -metadata-output platform.gti.json \
  -metadata-package example/platform \
  platform.gt -o platform.go

gotlinc build -src \
  -metadata platform.gti.json \
  service.gt -o service.go

Language server and editor

Build the language server:

go build -o ./bin/gotlin-lsp ./cmd/gotlin-lsp

The VS Code extension under tools/vscode-gotlin provides syntax highlighting, snippets, diagnostics, hover, document symbols, definitions, references, and a gopls bridge for imported Go APIs.

cd tools/vscode-gotlin
npm install
npm run check