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:
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
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:
} catch (e: Throwable) {
if (e is CancellationException) throw e
log(e)
}Data classes
Full support, equals, hashCode, toString, copy, componentN
auto-generated:
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
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
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)
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
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
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
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:
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
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:
"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)
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 // → 1User 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)
| Pattern | Error | Why |
|---|---|---|
android.util.Log.d(...) | DirectAndroidApiAccess | Android framework, capability-bridge it. |
kotlin.reflect.typeOf<T>() | ReflectionUsage | No class metadata in the KBC sandbox. |
MyClass::class.java | ReflectionUsage | Same. |
File("...").readText() | FileIoUsage | Use a storage capability. |
GlobalScope.launch { ... } | GlobalScopeUsage | Use viewModelScope. |
runBlocking { ... } | GlobalScopeUsage | Use a suspend function. |
MyCustomComposable() (no adapter) | UnregisteredComposable | Write an adapter or use a built-in. |
MyClass() constructor of a Compose-domain type | NonKbcConstructor | Add a constructor adapter. |
myFunction() not in module / capabilities | UnregisteredCall | Either add it to your module or expose as capability. |
Limits that produce silent surprises (no error)
try / catchis catch-all, first handler runs for any throwable. Workaround: branch onisinside the handler.- Generics are erased,
is List<Int>matches any list. Standard JVM behaviour. - Inheritance beyond
INVOKE_VIRTUALon direct method lookups is limited. Sealed-class +whenis 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).
Breadcrumbs in error messages
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
| Feature | Status |
|---|---|
@Composable lambda parameters | Full 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 →