typed-events
Define your analytics event schema in a YAML file and get a fully-typed
AnalyticsBase Kotlin class generated at build time. Override
a single logEvent method to connect any analytics backend —
Firebase, Mixpanel, Amplitude, or your own.
Overview
Analytics implementations suffer from a common problem: event names and parameter keys are stringly-typed, scattered across the codebase, and easy to misspell. The schema lives in someone's head or a Google Sheet — not enforced by the compiler.
typed-events solves this by making your schema the source of truth. You write a YAML file that describes every event — its name, a description, and typed parameters. The plugin generates top-level Kotlin functions, one per event, that forward to a handler you register once at startup. No inheritance, no boilerplate.
All parameters are real Kotlin types — compiler errors instead of runtime crashes.
Call registerTypedEventHandler once at startup to connect any backend.
Every function and parameter gets a doc comment from the YAML info fields.
The YAML lives in version control alongside the code it drives.
The generation task is fully incremental and compatible with Gradle's configuration cache.
Installation
Add Maven Central to your plugin repositories in settings.gradle.kts:
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
Then apply the plugin and point it at your schema file:
plugins {
id("com.rohittp.plugables.typed-events") version "1.0.0"
}
typedEvents {
specFile.set(file("src/main/analytics/events.yaml"))
}
specFile is the only required property. Everything else has
a sensible default.
Requires AGP 7.2+
The plugin wires generated sources via the Android Variant API
(AndroidComponentsExtension). Android Gradle Plugin 7.2 or
higher is required. The plugin is tested against AGP 9.1.0.
Recommended location
Place your schema at src/main/analytics/events.yaml.
Keeping it under src/main means it lives alongside the
code it describes and participates in standard VCS workflows.
YAML Schema
The schema is a YAML list where each item defines one analytics event. Two forms are supported: a verbose full form for events with parameters, and a one-line shorthand for parameter-less events.
Full form
Use the full form when an event carries parameters.
# Full form — use when the event has parameters
- purchase_completed:
info: User completed a purchase flow
function: logPurchaseCompleted # optional — auto-derived if omitted
params:
item_id:
type: String
info: Unique identifier of the purchased item
price:
type: Double
info: Final price paid after discounts, in the user's currency
currency:
type: String
info: ISO 4217 currency code (e.g. USD, EUR)
Full form fields
| Field | Required | Description |
|---|---|---|
info |
Yes | Human-readable description of the event. Becomes the KDoc comment on the generated method. |
function |
No |
Override the generated method name. If omitted, the name is derived from
event_name by camel-casing with a log prefix —
e.g., purchase_completed → logPurchaseCompleted.
|
params |
No | Map of parameter definitions. Omit entirely for parameter-less events. |
params.*.type |
Yes (per param) | Any valid Kotlin type string, e.g. String, Double, Boolean, String?. |
params.*.info |
Yes (per param) | Description of the parameter. Becomes a @param KDoc entry. |
Shorthand form
For parameter-less events, collapse the definition to a single line.
The value is the event description (the info field).
# Shorthand — event name: description
- screen_viewed: User navigated to a new screen
- app_opened: App launched or foregrounded
- logout_tapped: User tapped the logout button
Validation rules
The task validates the schema before generating. Build fails with a clear message if any rule is violated:
infomust be present and non-blank on every event and every parameter.- Event names must be unique within the file.
- Function names (derived or explicit) must be unique within the file.
- Each
paramsentry must have bothtypeandinfo. - Derived function names must be valid Kotlin identifiers.
Parameter name derivation
YAML parameter keys are converted to camelCase Kotlin identifiers by splitting
on non-alphanumeric characters, lowercasing the first segment, and title-casing
the rest. A leading digit causes a _ prefix.
| YAML key | Kotlin parameter name |
|---|---|
item_id | itemId |
screen_name | screenName |
price | price |
2fa_enabled | _2faEnabled |
Configuration
The typedEvents DSL block exposes two properties:
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
specFile |
RegularFileProperty |
Yes | — | Path to the YAML schema file. Build fails if unset or file not found. |
outputDir |
DirectoryProperty |
No | build/generated/source/typedEvents/main |
Where AnalyticsBase.kt is written. Automatically wired into the AGP main source set. |
All generated code is placed in the package
com.rohittp.plugables.analytics — this is fixed and not
configurable.
Generated API
Given the following schema combining both full and shorthand forms:
- purchase_completed:
info: User completed a purchase
params:
item_id:
type: String
info: The purchased item ID
price:
type: Double
info: Final price paid
- screen_viewed: User viewed a screen
The plugin generates two files:
TypedEventHandler.kt
The registration surface. Call registerTypedEventHandler once
at startup to wire in your backend.
package com.rohittp.plugables.analytics
// GENERATED FILE. Do not edit.
internal var typedEventHandler: ((eventName: String, params: Map<String, Any?>) -> Unit)? = null
/**
* Registers the analytics event handler.
*
* Call once at app startup, e.g. inside Application.onCreate:
* registerTypedEventHandler(::logEvent)
*/
fun registerTypedEventHandler(handler: (eventName: String, params: Map<String, Any?>) -> Unit) {
typedEventHandler = handler
}
AnalyticsEvents.kt
One top-level function per event. Each function validates that a handler
has been registered (via assert) then forwards to it.
package com.rohittp.plugables.analytics
// GENERATED FILE. Do not edit.
// Source: events.yaml
/**
* User completed a purchase
* @param itemId The purchased item ID
* @param price Final price paid
*/
fun logPurchaseCompleted(itemId: String, price: Double) {
assert(typedEventHandler != null) { "registerTypedEventHandler() must be called before logging events" }
typedEventHandler?.invoke("purchase_completed", mapOf("item_id" to itemId, "price" to price))
}
/** User viewed a screen */
fun logScreenViewed() {
assert(typedEventHandler != null) { "registerTypedEventHandler() must be called before logging events" }
typedEventHandler?.invoke("screen_viewed", emptyMap())
}
Note on assert
assert is only active when the JVM is run with
-ea. In production Android builds the check is a no-op,
so forgetting to register will silently drop events rather than crash.
Register the handler at the start of Application.onCreate()
to make it impossible to forget.
Usage
Register a handler once at startup, then call the generated top-level functions anywhere in the app.
Step 1 — Register the handler
Call registerTypedEventHandler in Application.onCreate()
before any event functions are invoked.
import com.rohittp.plugables.analytics.registerTypedEventHandler
class App : Application() {
override fun onCreate() {
super.onCreate()
registerTypedEventHandler { eventName, params ->
// forward to your backend here
}
}
}
Step 2 — Call the typed functions
The generated functions are top-level — import and call them directly. Full IDE autocomplete and compiler-checked types.
import com.rohittp.plugables.analytics.logPurchaseCompleted
import com.rohittp.plugables.analytics.logScreenViewed
logPurchaseCompleted(
itemId = "sku_abc123",
price = 9.99
)
logScreenViewed()
Integrations
The handler receives a plain String event name and a
Map<String, Any?>, making it trivial to bridge to any SDK.
Pass the lambda directly to registerTypedEventHandler.
Firebase Analytics
import com.rohittp.plugables.analytics.registerTypedEventHandler
registerTypedEventHandler { eventName, params ->
val bundle = Bundle().apply {
params.forEach { (key, value) ->
when (value) {
is String -> putString(key, value)
is Double -> putDouble(key, value)
is Int -> putInt(key, value)
is Boolean -> putBoolean(key, value)
null -> {}
else -> putString(key, value.toString())
}
}
}
firebase.logEvent(eventName, bundle)
}
Mixpanel
registerTypedEventHandler { eventName, params ->
val props = JSONObject(params.filterValues { it != null })
mixpanel.track(eventName, props)
}
No-op (testing / development)
Register an empty lambda for tests or debug builds where you don't want real events sent.
// No-op — swallow all events (tests, debug builds)
registerTypedEventHandler { _, _ -> }
// Logging — print events in debug builds
registerTypedEventHandler { eventName, params ->
Log.d("Analytics", "${eventName} ${params}")
}