No description
  • Go 98.7%
  • JavaScript 0.9%
  • TypeScript 0.4%
Find a file
2026-08-27 21:53:34 +02:00
cmd Infer coroutine effects automatically 2026-08-27 21:53:34 +02:00
examples Move LSP semantics into typed analysis 2026-08-27 21:28:11 +02:00
internal/lang Infer coroutine effects automatically 2026-08-27 21:53:34 +02:00
tools/vscode-gotlin Infer coroutine effects automatically 2026-08-27 21:53:34 +02:00
.gitignore Expand Gotlin language and tooling 2026-08-27 01:46:57 +02:00
go.mod Move LSP semantics into typed analysis 2026-08-27 21:28:11 +02:00
go.sum Move LSP semantics into typed analysis 2026-08-27 21:28:11 +02:00
hello.go init 2026-03-05 14:55:09 +01:00
imports.go Expand Gotlin language and tooling 2026-08-27 01:46:57 +02:00
README.md Infer coroutine effects automatically 2026-08-27 21:53:34 +02:00

Gotlin

Gotlin is a small Kotlin-like frontend implemented in Go that targets the Go toolchain.

This is the practical boundary of the prototype:

  • It is a Kotlin-flavored language frontend.
  • It targets the Go toolchain by generating valid Go source and building through go build.
  • It is not a direct integration into Go's internal cmd/compile backend APIs.

The compiler keeps source spelling in its syntax AST, then builds lexical symbols, structural semantic types, resolved expression meanings, and typed HIR before Go emission. Class reference semantics live in ClassType; only the semantic Type-to-Go mapping turns a class such as User into *User.

Supported language slice

  • fun declarations
  • val and var
  • Int, Long, String, Boolean, Unit
  • function types like (String) -> Unit
  • user generic functions and classes with inferred or explicit type arguments
  • if, else, while, and for (item in items)
  • function calls
  • lambdas like { x: Int -> println(x) } and { println(it) }
  • class with primary-constructor fields and methods
  • interface with method signatures
  • Rust-style algebraic enum declarations with payload variants and exhaustive match
  • println(...)
  • arithmetic, comparison, and boolean operators
  • decimal literals, defer, and Go address-of expressions such as &value
  • explicit nullable types (Type?), safe access (?.), and non-null assertions (!!)
  • Gotlin classes are reference types by default; * is only needed for external Go pointer types
  • named external Go struct construction, for example http.Client(timeout = 3 * time.second)
  • top-level embedded resources such as @embed("assets/*") val assets: embed.FS
  • inferred structured coroutine effects with runBlocking, coroutineScope, launch, async, await, delay, withTimeout, isActive, and coroutineContext

Example

package demo

fun fib(n: Int): Int {
    if (n < 2) {
        return n
    }
    return fib(n - 1) + fib(n - 2)
}

fun main() {
    println(fib(8))
}

Imports from Go packages are supported:

package demo

import strings

fun main() {
    println(strings.ToUpper("gotlin"))
}

HTTP server example:

package demo.web

import fmt
import net.http

fun helloHandler(w: http.ResponseWriter, r: *http.Request) {
    fmt.Fprintln(w, "hello from gotlin")
}

fun main() {
    http.HandleFunc("/", helloHandler)
    fmt.Println("serving http://localhost:8080")
    http.ListenAndServe(":8080", http.DefaultServeMux)
}

Classes and interfaces:

package demo

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

class ConsoleGreeter(val prefix: String) {
    fun greet(name: String): String {
        return prefix + name
    }
}

fun main() {
    val greeter: Greeter = ConsoleGreeter("hello, ")
    println(greeter.greet("gotlin"))
}

Rust-style enums:

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

fun describe(result: PaymentResult): String {
    return match (result) {
        PaymentResult.Accepted(id) -> id
        PaymentResult.Rejected(reason) -> reason
        PaymentResult.Pending -> "pending"
    }
}

Enum matches must contain each variant exactly once. Variant payload arity is checked during Gotlin compilation. A match used as an expression also requires every arm to return the same type. Block-style statement matches remain available for side effects.

User generics

Functions and classes may declare type parameters. Calls infer straightforward type bindings from arguments or accept explicit type arguments:

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

fun identity<T>(value: T): T { return value }

val number = identity(42)
val text = identity<String>("value")
val box = Box("boxed")

Type parameters currently use an implicit Any constraint. Generic methods with their own type parameters are intentionally deferred; place parameters on the enclosing class or a top-level function.

Null safety

Types are non-nullable by default. Add ? explicitly when null is valid:

fun email(user: User?): String? {
    return user?.email
}

The compiler rejects null in non-nullable arguments, fields, local variables, and return values. Nullable receivers cannot be dereferenced directly. Gotlin smart-casts values after value != null branches and guard clauses such as if (value == null) { return }. Use !! only when an invariant cannot be expressed through control flow:

val required: User = optionalUser!!

Error handling

Gotlin adapts Go (T, error) returns into Rust-style Result<T, Error> flows. An explicit Result return or variable type converts the Go return directly:

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

fun ping(db: *sql.DB): Result<Unit, Error> {
    return db.ping()
}

Use ? to propagate a Go error or chain a Gotlin function returning Result. Explicit panic and fallback operations are available when appropriate:

val required = parse("42").unwrap()
val fallback = parse("invalid").unwrapOr(0)

Gotlin never inserts implicit panic wrappers. A Go call returning (T, error) must have an explicit Result context or use ?, explicit value, error destructuring, or .unwrap().

Gotlin-defined class and data class values are references automatically, including nested generic types such as List<User>. Explicit pointer syntax is reserved for Go interop, for example *http.Request and *pgxpool.Pool; applying * to a Gotlin class is a compile error.

Enums whose variants carry no payload are represented as string-backed values. The exact variant identifier is used for JSON and PostgreSQL text values:

enum PaymentStatus {
    PENDING_RESERVATION
    INITIATED
}

This reads and writes "PENDING_RESERVATION" and "INITIATED" directly. Payload-carrying enums remain algebraic sum types.

Structural mapping

Compatible classes and enums can be converted with mapTo<T>():

data class AddressEntity(var city: String)
data class AddressResponse(var city: String)
data class AccountEntity(var id: String, var address: *AddressEntity)
data class AccountResponse(var address: *AddressResponse, var id: String)

val response = account.mapTo<AccountResponse>()

When the surrounding expression provides a target type, the type argument is optional:

fun response(account: AccountEntity): AccountResponse {
    return account.mapTo()
}

val response: AccountResponse = account.mapTo()
val envelope = Envelope(account.mapTo())

Use explicit mapTo<T>() when assigning to an untyped local or when no target type can be inferred.

Fields are matched by Gotlin name rather than declaration order. Mapping is recursive across nested classes, pointers, nullable values, lists, mutable lists, maps, and enum payloads. Enum variants are matched by name. Extra source fields and extra target enum variants are allowed; every target field and every source enum variant must be compatible.

Payloadless enums also map recursively to and from String. String-to-enum mapping validates the runtime value and panics for an unknown variant string.

Incompatible mappings fail compilation with a complete path, for example:

cannot map Account.address.zip: String is incompatible with Int
go run ./cmd/gotlinc build ./examples/hello.gt
./hello

Emit Go source instead:

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

Run directly:

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

Structured coroutines

Gotlin coroutines use Go goroutines underneath, but expose only structured scopes. A scope waits for its children, propagates child failures, and cancels sibling coroutine contexts. The removed worker, bare go, and channel select forms are not valid Gotlin syntax; use coroutine scopes, delay, and explicit channel read()/send() operations.

Coroutine effects are inferred through the call graph. Functions that directly or transitively use coroutine operations receive a hidden scope parameter and can only be called from an ambient coroutine scope. runBlocking establishes a scope boundary, so neither a suspend modifier nor manually threaded context is needed.

Use coroutineContext() only at Go interop boundaries that require a context.Context; it returns the ambient scope context without exposing it in the Gotlin function signature.

fun load(): Int {
    delay(10)
    return 42
}

fun main() {
    runBlocking {
        val value = async<Int> { return load() }
        launch { println("loading") }
        println(value.await())
    }
}

Type-checked SQL queries

Gotlin recognizes a PostgreSQL SQL DSL at compile time. SQL row mappings must be data classes annotated with @table. Fields map from lower-camel Gotlin names to snake_case columns by default and can override the SQL name with @column. Conflict keys use @id; database-generated or defaulted fields use @generated.

import time

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

fun accountsFor(customerId: String): GotlinSQLQuery {
    return sql.from<AccountRow>()
        .where { row -> row.customerId == customerId && row.closedAt == null }
        .orderByDescending { row -> row.balance }
        .limit(100)
        .build()
}

The compiler emits a Go value with this generated support type:

type GotlinSQLQuery struct {
    SQL  string
    Args []any
}

The example selects every field, including @generated fields, and produces PostgreSQL $n placeholders. Arguments are emitted in SQL traversal order. A literal limit(100) is embedded after validation; a typed non-literal Int limit uses the next placeholder.

Nullable types use a ? suffix, currently including forms such as String? and time.Time?; generated Go fields use pointers. Comparing a nullable field with null lowers to IS NULL or IS NOT NULL. Ordering comparisons support numeric values and time.Time; typed now() emits CURRENT_TIMESTAMP without an argument:

sql.from<OutboxRow>()
    .where { it.publishedAt == null && (it.claimedUntil == null || it.claimedUntil < now()) }
    .orderBy { it.createdAt }
    .limit(batchSize)
    .forUpdate()
    .skipLocked()
    .build()

The canonical select method order is:

sql.from<Row>()
    [.select { ... }]
    [.where { ... }]
    [.orderBy { ... } | .orderByDescending { ... }]
    [.limit(Int)]
    [.forUpdate()]
    [.skipLocked()]
    .build() | .fetch(pool, ctx) | .single(pool, ctx) | .iterator(pool, ctx)

Each optional method may occur at most once. select must be first, skipLocked requires forUpdate, and clauses are emitted as WHERE, ORDER BY, LIMIT, FOR UPDATE, SKIP LOCKED in PostgreSQL order.

Typed projections

Projection targets are local data classes. The constructor must contain one direct source-row field per target field, in target declaration order, with exact matching types:

data class AccountSummary(var id: String, var balance: Double)

fun summaries(pool: *pgxpool.Pool, ctx: context.Context): List<AccountSummary> {
    return sql.from<AccountRow>()
        .select { row -> AccountSummary(row.id, row.balance) }
        .orderBy { it.balance }
        .fetch(pool, ctx)
        .unwrap()
}

The compiler emits SELECT id, balance, scans in projection declaration order, and makes fetch, single, and iterator target AccountSummary rather than AccountRow. The row parameter in later where and ordering methods still represents AccountRow.

Select chains can execute directly against a pgx/v5 pool. fetch(pool, ctx) returns Result<List<AccountRow>, Error> and closes pgx rows after reading and scanning every result in data-class field declaration order:

import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool"

fun accountsFor(
    pool: *pgxpool.Pool,
    ctx: context.Context,
    customerId: String
): List<AccountRow> {
    return sql.from<AccountRow>()
        .where { it.customerId == customerId }
        .orderBy { it.accountType }
        .fetch(pool, ctx)
        .unwrap()
}

single(pool, ctx) returns Result<AccountRow, Error> and closes the rows. Use ? to propagate zero/multiple-row errors or call .unwrap() explicitly:

fun account(pool: *pgxpool.Pool, ctx: context.Context, id: String): AccountRow {
    return sql.from<AccountRow>()
        .where { it.id == id }
        .single(pool, ctx)
        .unwrap()
}

iterator(pool, ctx) returns a Result around a streaming pgx iterator. Call next() before each value(), arrange an explicit close(), and inspect err() after iteration:

fun printAccounts(pool: *pgxpool.Pool, ctx: context.Context) {
    val rows = sql.from<AccountRow>().iterator(pool, ctx).unwrap()
    defer rows.close()

    while (rows.next()) {
        val account: AccountRow = rows.value()
        println(account.customerId)
    }

    val checked = rows.err()
}

Query and scan failures are returned as Result errors. fetch and single close rows internally; an iterator leaves lifecycle control with the caller, and value() panics only when called without a successful next(). None of the execution terminals require an intermediate build(), query.sql, or query.args access. build() remains available for manual execution.

Inserts and returning

Inserts omit every @generated field. If all fields are generated, the compiler emits INSERT ... DEFAULT VALUES. Conflict handling is optional; conflict fields must have @id:

sql.insert<AccountRow>(row)
    .onConflict { it.id }
    .doNothing()
    .build()

Composite conflict keys use listOf, for example .onConflict { listOf(it.tenantId, it.id) }.

Updates use a typed lower-camel set form because Gotlin does not currently implement Kotlin callable references (AccountRow::balance):

sql.insert<AccountRow>(row)
    .onConflict { it.id }
    .doUpdate { excluded -> set(AccountRow.balance, excluded.balance) }
    .build()

Multiple set(...) expressions may appear in one doUpdate lambda. Update targets must exist and be var; excluded fields must exist and have a compatible type.

An insert can return the full row or a typed projection. Write chains can use fetch, single, or iterator only after returning:

sql.insert<AccountRow>(row)
    .returning { it }
    .single(pool, ctx)

sql.insert<AccountRow>(row)
    .onConflict { it.id }
    .doNothing()
    .returning { value -> AccountSummary(value.id, value.balance) }
    .single(pool, ctx)

The exact insert forms are:

sql.insert<Row>(row)
    [.onConflict { field | listOf(fields...) }.doNothing() | .doUpdate { ... }]
    [.returning { it | Projection(it.field, ...) }]
    .build() | returning execution terminal

Updates and deletes

Typed updates use one set lambda. Targets are mutable source-row fields. Values may be typed names/selectors, literals, null for nullable targets, now() for timestamps, source-row fields, or numeric + and - expressions:

sql.update<AccountRow>()
    .set { row ->
        set(row.balance, row.balance + amount)
        set(row.closedAt, now())
    }
    .where { it.id == accountId }
    .returning { row -> AccountSummary(row.id, row.balance) }
    .single(pool, ctx)

Deletes support a typed predicate and the same returning forms:

sql.delete<AccountRow>()
    .where { it.id == accountId }
    .returning { it }
    .single(pool, ctx)

The exact write forms are:

sql.update<Row>()
    .set { row -> set(row.field, value); ... }
    [.where { predicate }]
    [.returning { it | Projection(it.field, ...) }]
    .build() | returning execution terminal

sql.delete<Row>()
    [.where { predicate }]
    [.returning { it | Projection(it.field, ...) }]
    .build() | returning execution terminal

where is intentionally optional for updates and deletes, so omitting it affects the whole table. There is no write execute terminal yet; use build() for manual Exec, or add returning and use a query execution terminal.

At compile time the DSL checks that the generic row type exists, is a data class with @table, has unique mapped columns, and contains every referenced field. It checks known predicate operand types, nullable operations, insert row types, @id conflict metadata, update mutability and value compatibility, method cardinality/order, lock dependencies, and projection field counts/types.

Current SQL limitations:

  • Predicates support &&, ||, !, ==, !=, and numeric/timestamp <, <=, >, >=. Values must have a compiler-known type; external values become $n arguments.
  • Projections and returning do not support scalar results, aliases, computed expressions, aggregates, external structs, or reordered/coerced target types. They accept a full row or direct fields passed to a local data-class constructor.
  • Standalone update values do not support SQL functions other than now(), string expressions, intervals, casts, subqueries, or arbitrary SQL fragments. Upsert doUpdate values still come only from the excluded row.
  • Inserts emit one row at a time. Bulk/multi-row inserts are not implemented.
  • Table and column annotations are validated unquoted SQL identifiers. Joins, aliases, grouping, aggregates, OFFSET, lock strengths other than FOR UPDATE, and conflict predicates are not implemented.
  • The compiler does not perform migrations, schema generation, database connections, or schema introspection.

Language server:

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

VS Code extension:

cd ./tools/vscode-gotlin
npm install
npm run build

Notes

  • val is immutable after initialization; var can be reassigned.
  • Type inference is local to declarations without an explicit type.
  • Top-level declarations currently support functions, classes, and interfaces.
  • gotlinc build produces an executable by default. If -o is omitted, the output name is derived from the input file name.
  • gotlinc build -src emits Go source instead of a binary.
  • gotlinc supports build and run, and defaults to build if no subcommand is given.
  • Gotlin source files use the .gt extension.
  • gotlin-lsp provides diagnostics, hover, and go-to-definition over stdio.
  • gotlin-lsp can optionally use gopls for hover and definition on Go-imported symbols.
  • the VS Code extension adds syntax highlighting, snippets, and launches the LSP for .gt files.