Ketoy
Guides

Kotlin Language

Ketoy executes a well-defined **subset** of Kotlin at runtime. This page covers what you can use inside a `@KetoyComposable` / `@KetoyEntryPoint` / `@KetoyViewModel` and what the compiler rejects.

The shorter version: write idiomatic Kotlin. Data classes, sealed classes, when, lambdas, coroutines, generics, extension functions — all supported. The boundary is at the system interface: Android APIs, reflection, file I/O, raw Java interop need to go through capabilities.


Primitive types

Int, Long, Float, Double, Boolean, String, Char, Unit, null. Full arithmetic (+ - * / %), comparison (==, !=, <, <=, >, >=, ===), null-checks (?:, ?., !!). Boolean operators (&&, ||, !). Bitwise on Int (or, and, xor, shl, shr, ushr). Int↔Long/Float/Double conversions. Boxing / unboxing of nullable primitives.


Strings

String is a first-class type. + concatenation ("a" + b), template interpolation ("$x $y"), .length, .substring(start, end). The string pool deduplicates literals per bundle.


Control flow

All standard Kotlin control flow:

kotlin
if (x > 0) doA() else doB()

when (state) {
    is Loading -> Spinner()
    is Error -> ErrorText(state.message)
    is Success -> Content(state.value)
}

for (item in list) { process(item) }
items.forEach { process(it) }

while (active) { tick() }
do { tick() } while (active)

repeat(3) { i -> log(i) }

break / continue work. return returns from the enclosing function.

try / catch / finally

kotlin
try {
    risky()
} catch (e: NetworkException) {
    log(e)
} catch (e: Exception) {
    setState("error", e.message)
} finally {
    setState("loading", false)
}

Caveat: catch is currently catch-all. The first handler runs for any exception type — multi-catch falls through to the first one. Always explicitly re-throw CancellationException to preserve structured cancellation:

kotlin
} catch (e: Throwable) {
    if (e is CancellationException) throw e
    log(e)
}

Data classes

Full support — equals, hashCode, toString, copy, componentN auto-generated:

kotlin
data class Todo(val id: Long, val title: String, val completed: Boolean)

@KetoyComposable
@Composable
fun TodoRow(todo: Todo) {
    val (id, title, completed) = todo  // destructuring works
    Row {
        Checkbox(checked = completed, onCheckedChange = { /* ... */ })
        Text(title)
    }
}

copy() creates new instances; structural equality is automatic.


Sealed classes / sealed interfaces

kotlin
sealed interface UiState {
    object Loading : UiState
    data class Error(val message: String) : UiState
    data class Content(val items: List<Item>) : UiState
}

@KetoyComposable
@Composable
fun Screen(state: UiState) {
    when (state) {
        is UiState.Loading -> CircularProgressIndicator()
        is UiState.Error -> Text("Error: ${state.message}")
        is UiState.Content -> Column { state.items.forEach { ItemRow(it) } }
    }
}

is (INSTANCEOF), as (CAST), as? (SAFE_CAST), exhaustiveness checking — all work.


Enum classes

kotlin
enum class Priority { LOW, MEDIUM, HIGH }

fun colorFor(p: Priority): Color = when (p) {
    Priority.LOW -> Color.Gray
    Priority.MEDIUM -> Color.Yellow
    Priority.HIGH -> Color.Red
}

values() and valueOf() work via stdlib intrinsics.


Objects (singletons)

kotlin
object AppDefaults {
    const val MAX_ITEMS = 50
    val PADDING = 16
}

fun render() = Column(modifier = Modifier.padding(AppDefaults.PADDING.dp)) { /* ... */ }

Companion-object properties are emitted as static-style getters and resolved by the validator + ComposeTokenRegistry.


Functions

Top-level functions

kotlin
fun formatCurrency(amount: Long): String = "$${amount / 100.0}"

@KetoyComposable @Composable
fun PriceTag(price: Long) { Text(formatCurrency(price)) }

Same-module top-level functions are auto-included in the closure walk. You don't need @KetoyComposable on plain helpers — only on entry points and on @Composable functions that participate in adapter resolution.

Extension functions

kotlin
fun List<Todo>.completedCount(): Int = count { it.completed }

@KetoyComposable @Composable
fun Header(todos: List<Todo>) {
    Text("${todos.completedCount()} done")
}

The receiver becomes register 0 of the helper function.

Inline functions

inline fun is inlined by the Kotlin frontend before the IR extension runs — the compiler sees the inlined body. Reified type parameters work if the use site is concrete.

Higher-order functions / lambdas

kotlin
val onClick: () -> Unit = { count++ }
items.forEach { item -> render(item) }

Lambdas with captured outer-scope locals are fully supported via closure conversion (the compiler analyses each lambda, records the captures, lowers them to function parameters threaded through KBCValue.ClosureRef).


Generics

Erased at IR time — KBC sees the same shape Kotlin would. Type checks (is List<String>) only match the erased type:

kotlin
val x: Any = listOf(1, 2, 3)
if (x is List<*>) { /* OK */ }
if (x is List<Int>) { /* same as List<*> at runtime */ }

This matches JVM behaviour.


Collections

kotlin
val list = listOf("a", "b", "c")
val map = mapOf("k" to "v", "q" to "r")
val mutable = mutableListOf<Int>().apply { add(1); add(2) }

list.size
list[0]
list.forEach { println(it) }
list.map { it.uppercase() }
list.filter { it.startsWith("a") }

listOf, emptyList, mapOf, mutableListOf are intrinsified and lowered to KBC collection opcodes (LIST_NEW, MAP_NEW, …). Bigger stdlib operations route through INVOKE_VIRTUAL against the KBC heap.

If you call a stdlib function that isn't in the validator's allowlist, you'll get UnregisteredCall with a fuzzy-match suggestion. Add the FQ name to your ketoy-capabilities.json's allowedStdlibFqNames list to permit it:

json
"allowedStdlibFqNames": [
  "kotlin.collections.listOf",
  "kotlin.collections.emptyList",
  "kotlin.let",
  "kotlin.run",
  "kotlin.also",
  "kotlin.apply",
  "kotlin.with",
  "kotlin.takeIf",
  "kotlin.requireNotNull",
  "kotlin.error",
  "kotlin.TODO"
]

Classes (user-defined)

kotlin
class Counter(initial: Int = 0) {
    var value: Int = initial
        private set

    fun increment() { value++ }
    fun reset() { value = 0 }
}

val c = Counter()
c.increment()
c.value      // → 1

User class instances live on the KBC heap (NEW_INSTANCE, GET_FIELD, SET_FIELD, INVOKE_VIRTUAL). Inheritance is limited — there's no virtual-dispatch table beyond the immediate INVOKE_VIRTUAL lookup. Adapter-routed Compose types and registered capabilities are the supported way to reach platform behaviour. For sealed-class hierarchies you use yourself in KBC, virtual dispatch via INVOKE_VIRTUAL works fine.


What you can't do

Hard non-goals (compile errors)

PatternErrorWhy
android.util.Log.d(...)DirectAndroidApiAccessAndroid framework — capability-bridge it.
kotlin.reflect.typeOf<T>()ReflectionUsageNo class metadata in the KBC sandbox.
MyClass::class.javaReflectionUsageSame.
File("...").readText()FileIoUsageUse a storage capability.
GlobalScope.launch { ... }GlobalScopeUsageUse viewModelScope.
runBlocking { ... }GlobalScopeUsageUse a suspend function.
MyCustomComposable() (no adapter)UnregisteredComposableWrite an adapter or use a built-in.
MyClass() constructor of a Compose-domain typeNonKbcConstructorAdd a constructor adapter.
myFunction() not in module / capabilitiesUnregisteredCallEither add it to your module or expose as capability.

Limits that produce silent surprises (no error)

  • try / catch is catch-all — first handler runs for any throwable. Workaround: branch on is inside the handler.
  • Generics are erasedis List<Int> matches any list. Standard JVM behaviour.
  • Inheritance beyond INVOKE_VIRTUAL on direct method lookups is limited. Sealed-class + when is the recommended pattern.

The closure walk

When the compiler sees a @KetoyComposable / @KetoyEntryPoint / @KetoyViewModel, it walks the entire transitive call graph reachable from that root, validating every call along the way. Same-module unannotated top-level functions and extension functions are auto-included.

Functions that are not reachable from any KBC root compile straight to DEX as plain Kotlin and ship with the next APK release — the closure walk leaves them untouched. This is how you can have a single :app module containing both KBC source (e.g. ketoyscreens/HomeScreen.kt) and native fallbacks (e.g. ui/HomeNativeFallback.kt).

Errors detected in a transitively-reached helper carry a call chain:

KetoyBC: Direct access to 'android.util.Log.d' is not allowed in KBC programs.

  Reached via: HomeScreen → renderHeader → logHelper

  Android APIs are not available in the KBC execution sandbox.
  ...

The chain follows the call path from the entry-point root to the offending helper.


Compose-specific Kotlin features

FeatureStatus
@Composable lambda parametersFull support including content slots.
@Composable extension lambdas (RowScope.() -> Unit)Resolved as plain content slots today; type-aware dispatch is a roadmap item.
LaunchedEffect(key) { … }Lowered to COMPOSE_LAUNCHED_EFFECT. Cancels when key changes.
SideEffect { … }Lowered to COMPOSE_SIDE_EFFECT.
DisposableEffect(key) { … onDispose { … } }Lowered to COMPOSE_DISPOSABLE_EFFECT.
remember { … }COMPOSE_REMEMBER.
mutableStateOf(initial)COMPOSE_STATE.
derivedStateOf { … }COMPOSE_DERIVED.
produceState(initial) { … }Not yet — wrap as a Flow + collectAsState.
rememberCoroutineScope()Use viewModelScope (preferred) or implement as a capability.
Modifier.composed { … }Not yet — use a custom modifier op via an adapter.

What about expect/actual?

KBC source is single-target (Android). expect/actual declarations aren't applicable — your KBC bundle is an Android-only artifact.

For sharing logic between KBC and platform-native code, prefer:

  • Hoisting pure logic into a KMP module that both can depend on.
  • Or duplicating tiny helpers (often clearer than a shared module for small surfaces).

Next: KBC Opcodes →