A typealias is just a second name for an existing type: the compiler substitutes the real type everywhere, so nothing new appears in the bytecode and UserId stays fully interchangeable with Long.
Good uses: shorten long generic or function types (typealias Reducer<S, A> = (S, A) -> S) and give a callback type a readable name.
No safety: fun ban(id: UserId) happily accepts any Long, including an order id. When mixing up two ids is the bug you fear, use a @JvmInline value class.
Invisible outside the compiler: Java, reflection and stack traces see only the underlying type.
**vs fun interface**: an alias for (Int) -> Unit is that function type itself, so any matching lambda fits and Java sees Function1. A fun interface is a real named type: Java can implement it, it can carry extra members, and an unrelated lambda type is not silently accepted.
AndroidMiddle
Kotlin Advanced
Generics, inline/reified, delegates, DSL builders, type aliases
Questions closed
0 / 23
- 0Review
- 5Asked often
Questions
Generic types are invariant by default; out T makes a type covariant because it only produces T, and in T makes it contravariant because it only consumes T. This is Kotlin's version of Java's Producer-Extends, Consumer-Super.
Invariant: MutableList<Dog> is not a MutableList<Animal>, otherwise you could add a Cat to a list of dogs.
**out T (covariant)**: List<out E> only returns elements, so a List<Dog> can be passed where List<Animal> is expected. T may appear only in output positions.
**in T (contravariant)**: Comparator<in T> only takes T, so a Comparator<Animal> can sort dogs.
Declaration-site vs use-site: Kotlin lets you write out/in once on the class, while Java repeats ? extends at every use. For an invariant class you can still project at the use site: fun copy(from: Array<out Any>, to: Array<in Any>).
Star projection: List<*> means "a list of something unknown": you read elements as Any? and cannot add anything. It is Java's List<?>.
PECS mapping: out = ? extends, in = ? super, * = ?.
Nothing is a subtype of every type, and with out T that subtyping carries over to the container, so UiState<Nothing> is a subtype of every UiState<T> and one Loading singleton serves all screens.
Without out the interface is invariant: UiState<Nothing> and UiState<User> become unrelated and both assignments fail with a type mismatch. You would need Loading as a generic class and a new instance per type.
**Nothing**: the type with no values, at the bottom of the hierarchy; it is the type of throw and the return type of TODO(), and emptyList() is one shared List<Nothing>.
**Price of out**: T may appear only in output positions, so UiState<T> cannot declare fun with(value: T); Error is fine because it does not mention T.
On the JVM generic arguments are erased: at runtime the object only knows it is a List, so is List<String> cannot be checked and is a compile error, while as List<String> checks only the List part and lets wrong elements through.
Unchecked cast: the cast succeeds for listOf(1, 2, 3). The ClassCastException ("Integer cannot be cast to String") appears at the first read of an element as String, far from the real bug. That delayed failure is what the warning is about.
What is allowed: payload is List<*>: the star projection says "a list of unknown elements", which you read as Any?.
Safe version: (payload as? List<*>).orEmpty().filterIsInstance<String>() checks every element and returns a real List<String>; it silently drops non-String elements, so use map { it as String } if a wrong element should fail loudly.
**reified does not undo erasure**: it gives an inline function the class at the call site, so is T works for T = String, but for T = List<String> the check is still only "is a List".
Better design: turn Any into typed models once, at the boundary (JSON, Bundle, Intent extras), instead of casting inside business logic.
An inline function's body and its lambda arguments are copied into each call site, so no Function object is allocated and no extra call happens; noinline keeps one lambda as a real object, and crossinline forbids a non-local return from a lambda run in another context.
What you gain: no lambda allocation on hot paths (that is why map, filter, forEach, let and apply are inline), a non-local return from the lambda, and reified type parameters.
**noinline**: needed when the lambda must be stored or passed to a non-inline function, e.g. pending += task.
**crossinline**: needed when the lambda is invoked from another lambda or object inside the body (handler.post { block() }). The caller can then use only a labelled return@name.
When it hurts: a large body copied into hundreds of call sites grows the bytecode and the dex. A function with no function-type parameters and no reified gains nothing, and the compiler warns: "expected performance impact from inlining is insignificant".
Restrictions: a public inline function may use only public or @PublishedApi declarations, and no inline function can call itself recursively.
The lambda is inlined into save(), where a bare return means "return from save", but post runs it later inside a Runnable, when save has long returned, so the compiler rejects it: "Can't inline 'block' here: it may contain non-local returns".
**Fix 1: crossinline block: () -> Unit**: the lambda is still inlined, now into the Runnable's body, and callers may only write a labelled return@runOnUi, which exits just the lambda. This is the usual fix.
**Fix 2: noinline block: () -> Unit**: the lambda stays an ordinary object handed to post. The same return restriction applies plus an allocation, and with no inlined lambda left inline gains nothing, so you would drop it.
The rule: any inlined lambda that is invoked from a nested lambda or an anonymous object (for example a Runnable or a coroutine body) must be crossinline, because a non-local return would have no frame to return from.
In the caller: after the fix, runOnUi { if (isFinishing) return@runOnUi } compiles.
reified makes a type parameter available at runtime: because the inline body is copied into each call site, the compiler substitutes the concrete type there, so T::class, is T and as T work.
Why only inline: a normal generic function is compiled once and T is erased, so there is nowhere to put the type. Inlining produces a copy per call site where T is known.
Typical uses: context.launch<DetailsActivity>() instead of passing DetailsActivity::class.java, filterIsInstance<T>(), by viewModels<T>(), Json.decodeFromString<T>().
Java: cannot call a function with a reified parameter at all, so give Java an overload that takes Class<T>. A plain inline function without reified is callable from Java, but runs as a normal call: nothing is inlined.
Limits: reified gives the class, not its generic arguments (T = List<User> is just List), and you cannot pass a non-reified T from another generic function into it.
val x by d hands the property's getter and setter to the object d: the compiler stores d in a hidden field and turns every read into d.getValue(this, ::x) and every write into d.setValue(this, ::x, value).
**lazy { }: computed on the first read, then cached; synchronized by default.
Delegates.observable(initial) { prop, old, new -> }: runs a callback after every assignment.
Delegates.vetoable(initial) { prop, old, new -> Boolean }: runs before the assignment and can reject the new value.
Delegates.notNull()**: like lateinit, but also works for Int, Boolean and other primitive types, which lateinit does not allow.
**by map**: reads the property from a Map by its name; handy for dynamic config.
Custom: implement ReadOnlyProperty / ReadWriteProperty, or just the operator fun getValue / setValue.
On Android you use delegates every day: by viewModels(), by navArgs(), and Compose's by remember { mutableStateOf(...) }, where State is read through a getValue extension.
Yes by default: lazy { } uses SYNCHRONIZED, so the initializer runs exactly once even if several threads read the property at the same moment; the other two modes trade that guarantee for less locking.
**SYNCHRONIZED (default): double-checked locking; other threads block until the first initialization finishes. If the initializer throws, nothing is cached and the next read tries again.
PUBLICATION: no lock; several threads may run the initializer concurrently, but only the first returned value is stored and everyone sees that one. Fine for a pure, cheap initializer.
NONE: no synchronization at all; under concurrent access the initializer can run twice and threads can see different instances. Use it only when access is confined to one thread, such as an object read only on the main thread.
Cost**: once initialized, a SYNCHRONIZED lazy reads a @Volatile field without taking the lock, so the default is rarely a bottleneck.
Pitfall: two lazies whose initializers read each other from two threads can deadlock, because each thread holds one lock and waits for the other.
Delegates.observable fires only when the property is reassigned, and items.add(item) mutates the same list through the getter, so the callback never runs and the screen misses every addition; clear() works only because it assigns a new list.
What the delegate watches: observable wraps the setter: setValue stores the new reference and then calls the callback. items.add() calls getValue, gets the list and changes it in place, so the delegate never hears about it.
The fix: expose a read-only List<String> and replace it: items = items + item builds a new list and goes through the setter, so every change is one callback with a full snapshot.
Why the read-only type matters: private set blocks assignment from outside, not store.items.add(). With List<String> a mutation that bypasses the callback no longer compiles.
The same bug elsewhere: MutableStateFlow<MutableList<T>>, mutableStateOf(mutableListOf()) in Compose and LiveData also stay silent when you mutate the held object; StateFlow even drops a re-assignment of the same instance because it compares by equals. Keep immutable values and write _state.update { it + item }.
@JvmInline value class UserId(val raw: Long) is a distinct type at compile time, but at runtime the compiler uses the underlying Long directly wherever it can, so you get type safety without an object per value.
Rules: exactly one val in the primary constructor, no identity (=== is not allowed), no extra backing fields; init blocks, methods and interface implementations are allowed.
When it boxes: as a nullable type over a primitive (UserId?), as a generic argument (List<UserId>, any T), and when used as an interface it implements or as Any.
Name mangling: a function taking a value class gets a mangled JVM name such as ban-<hash>, so Java cannot call it normally. Kotlin 2.2 added the experimental @JvmExposeBoxed to generate Java-friendly versions.
**vs typealias: an alias is only another name; a value class is a new type the compiler checks.
vs data class**: a data class holds several fields but always allocates.
Good uses: ids, Email, money in minor units, units of measure. Compose's Dp and Color are value classes.
They change the JVM shape of a Kotlin declaration so Java can call it naturally: a real static method, the overloads that default arguments imply, a plain field, or a different JVM name.
**@JvmStatic**: a member of a companion object or object becomes a real static method; without it Java writes Config.Companion.load() or Analytics.INSTANCE.track().
**@JvmOverloads**: generates telescoping overloads for default arguments. The classic case is a custom View constructor (context, attrs = null, defStyleAttr = 0), which XML inflation calls with two arguments.
**@JvmField**: exposes a property as a public field with no getter or setter; const val does the same for compile-time constants.
**@JvmName**: renames a function or the file facade (StringUtils instead of StringUtilsKt) and resolves a platform declaration clash, when two functions erase to the same JVM signature.
**@Throws**: Kotlin has no checked exceptions, so Java cannot catch (IOException e) unless the function declares it.
**@JvmSuppressWildcards**: Kotlin emits Set<Tracker> in a parameter as Set<? extends Tracker>; Dagger and Hilt match types literally, so multibound Set/Map injections often need it.
Also mind use-site targets: an annotation from a Java library on a constructor property may need @field: or @get: to land on the right generated element.
Kotlin's null safety covers only code the Kotlin compiler checked; nulls slip in through Java platform types, reflection-based deserialization, !! and lateinit, unchecked generics and initialization order.
Platform types: a value from unannotated Java is String!; you may assign it to String with no check, and the crash comes at first use. Annotated Java (@Nullable, @NonNull, JSpecify) gives proper types; AndroidX and most of the Android SDK are annotated.
Gson: a class without a no-arg constructor is created through Unsafe without running any constructor, so a missing JSON field leaves val name: String null and defaults are ignored (if every parameter has a default, a no-arg constructor exists and this does not happen). Moshi codegen and kotlinx.serialization call the real constructor and either use the default or fail loudly.
**!! and lateinit: assertions, not proofs.
Generics**: an unbounded <T> means <T : Any?>, so T can be null; write <T : Any> when it must not. Unchecked casts can also smuggle nulls into a List<String>.
Initialization order: an open method called from a superclass constructor reads subclass properties before they are assigned.
The boundary check: Kotlin inserts Intrinsics.checkNotNullParameter into public functions, so a Java caller passing null fails immediately, naming the parameter.
The fix is architectural: validate at the edge (network DTOs, database rows, Java APIs) and map into domain types whose nullability you can trust.
An annotation is metadata on a declaration; its retention decides where it survives (source only, the class file, or runtime reflection), and annotation processors read it at compile time to generate code, which is how Room, Hilt and Moshi codegen work.
Retention: AnnotationRetention.SOURCE exists only for the compiler and processors, BINARY is stored in the class file but invisible to reflection, and RUNTIME (the Kotlin default) is readable via reflection, which is how Retrofit reads @GET.
Targets: @Target limits where an annotation may appear; on a property, use-site targets (@field:, @get:, @param:) decide which generated element receives it.
kapt: runs Java annotation processors by first generating Java stubs for every Kotlin source file, which is slow; it is in maintenance mode.
KSP: Kotlin Symbol Processing reads Kotlin symbols directly with no stubs and is typically around twice as fast; KSP2 is the current implementation. Room, Dagger/Hilt, Moshi and Glide support it, so new code uses ksp(...) and old modules migrate off kapt.
Generate rather than reflect: on Android, runtime reflection is slow, kotlin-reflect adds megabytes, and R8 strips or renames what reflection looks for unless you add keep rules.
Opt-in marks an API as experimental or dangerous: its author annotates it with a marker, and every caller must acknowledge that marker in code, so a breaking change can only hit people who accepted the risk.
The marker: an annotation class annotated with @RequiresOptIn(message = "...", level = ...); ERROR blocks compilation, WARNING only warns.
Accept locally: @OptIn(ExperimentalMaterial3Api::class) on a function, class or file; the requirement stops there.
Propagate: annotate your own declaration with the marker itself, and your callers must opt in too.
Whole module: compilerOptions { optIn.add("kotlinx.coroutines.ExperimentalCoroutinesApi") } in the Gradle kotlin block.
Markers you meet: ExperimentalMaterial3Api, ExperimentalCoroutinesApi (for example on flatMapLatest), and DelicateCoroutinesApi on GlobalScope, which is stable but easy to misuse.
Not deprecation: @Deprecated says the API is going away; opt-in says it works today but may change.
Define your own for a public-but-unstable API of a shared module, or for a dangerous call that should be a conscious decision at every call site.
The body of an inline function is copied into every call site, so it becomes part of your published binary surface: it may only touch declarations the caller can see, and callers keep the copy they were compiled against until they are rebuilt.
The consequences
Visibility: a public inline function cannot reference private or internal declarations: "Public-API inline function cannot access non-public-API". @PublishedApi internal is the escape hatch (public in the bytecode, still internal for Kotlin source). Renaming or deleting such a declaration later is a binary-incompatible change.
Binary compatibility: a bug fix inside an inline function does not reach already-compiled callers. An app that depends on library A, which was compiled against your 1.0, keeps the 1.0 body even after you ship 1.1. Treat an inline body in a library as API.
Code size: the body lands at every call site, inflating methods and the dex; keep inline bodies small and move the bulk into a regular (@PublishedApi internal) function they call.
Nothing to gain without lambdas: with no function-type parameter and no reified, inlining saves nothing and the compiler warns.
When it is still worth it
A reified type parameter or a non-local return from a lambda, both of which exist only because the body is inlined.
A tiny higher-order helper on a hot path.
Rule of thumb: in a published library, inline is a commitment, not an optimization switch.
guarded unlocks only after block returns normally. The early return in refresh() is a non-local return from an inlined lambda: it leaves refresh() directly, skips unlock() and the lock stays held for good. An exception from fetchToken() does the same.
**Why return skips the unlock**: the lambda body is inlined into refresh(), so return jumps straight out of refresh() and the .also { unlock() } after block() never runs. It happens on every call where the token is already fresh, so from the second call on.
Why only other threads hang: ReentrantLock counts holds per thread: the owner thread re-enters fine (its hold count just keeps growing), every other thread blocks in lock() forever.
The fix: try { return block() } finally { unlock() }: finally runs on a normal exit, a non-local return and an exception. In real code use the stdlib withLock, written exactly this way. Marking block as crossinline would forbid the return but still leak the lock on an exception.
How to catch it: in review, any inline function that acquires something, calls a lambda and releases it must use try/finally (or use). A test that takes the early-return path and then asserts lock.holdCount == 0 fails at once.
reified captures the call site's class, not its type arguments: for T = List<User>, T::class is just List, so Gson is told to build a list and fills it with its default LinkedTreeMaps, and the ClassCastException lands wherever you first read a field.
What reified can and cannot see
The compiler substitutes the concrete type at each call site, which makes T::class, is T and as T compile. All three are class-level questions, and the User inside List<User> is erased like any other generic argument.
typeOf<T>() (kotlin.reflect.typeOf, stable since 1.6, requires a reified T) returns a full KType with its arguments: typeOf<List<User>>() keeps the argument: kotlin.collections.List<User>.
Any API whose parameter is Class<T> or KClass<T> throws the arguments away by construction. That is the actual defect in the helper, not Gson.
The fixes, one per library
kotlinx.serialization: serializer(typeOf<T>()) is how its own reified decodeFromString<List<User>>() works, which is why the built-in overload is correct where a hand-rolled Class-based one is not.
Gson: a super-type token, object : TypeToken<T>() {}.type, which encodes the full type into an anonymous subclass's supertype.
Moshi: Types.newParameterizedType(List::class.java, User::class.java).
Mental model: reified answers "which class", typeOf answers "which type". A generic payload needs the second.
by expr evaluates the expression once per property and stores the result in a hidden field, so two properties handed the same delegate object share every field that object holds; the only thing that tells them apart at call time is the KProperty passed to getValue/setValue.
The mechanism
For var theme: String by Setting(), the compiler creates a synthetic theme$delegate field and rewrites reads to theme$delegate.getValue(this, ::theme) and writes to setValue(this, ::theme, value).
thisRef is the owner instance and property is the property being accessed. property.name is where a preferences key, bundle key or column name should come from, never from state cached inside the delegate.
State in a delegate is per delegate object, not per property. One shared instance means one shared value; the fix is a fresh delegate in each by clause, or keying everything by property.name.
by lazy { } avoids the trap by construction: each lazy call creates its own Lazy. On a class property it is per instance; on an object or a top-level property it is a process-wide singleton.
provideDelegate
operator fun provideDelegate(thisRef: Any?, property: KProperty<*>) runs once, when the property is initialized, and returns the real delegate: the hook for validating a key, registering the property somewhere, or building a delegate that depends on the name.
PropertyDelegateProvider is the stdlib interface for the same operator.
Rule of thumb: the delegate object is where the state lives, so give each property its own, or key everything by property.name.
A Kotlin DSL is ordinary code built on lambdas with receiver: a function takes block: Builder.() -> Unit, creates the builder, runs the block with the builder as this, and returns the result, while @DslMarker stops nested blocks from silently calling an outer builder.
Building blocks
Lambda with receiver: inside Builder.() -> Unit the builder's members are callable without a qualifier, which is what makes item("Share") { icon = ... } read like configuration.
**apply**: create and configure in one call: MenuBuilder().apply(block).build().
Extension functions: add verbs to builders you do not own, as Gradle plugins do in build.gradle.kts.
Infix functions and operators: polish such as Exposed's Users.id eq 1; use sparingly, they hurt discoverability.
Why @DslMarker
In nested lambdas every outer receiver stays implicitly available, so item(...) written inside an item { } block resolves to the outer MenuBuilder and quietly adds a sibling.
Annotating all builders with one marker makes such implicit calls a compile error; the outer scope is still reachable explicitly, e.g. this@menu.item(...).
Design advice
Keep builders mutable only during the block, return an immutable result, and validate in build().
Known examples: the Gradle Kotlin DSL, Ktor routing, kotlinx.html, Koin modules. Compose uses the same receiver-scoped idea for RowScope.weight and BoxScope.align.
The standard library function declares a contract (a promise to the compiler about what its return value implies about its arguments), while your helper is an opaque call, so the compiler cannot know that false means "not null" and drops the smart cast.
What contracts express
returns(false) implies (this@isNullOrEmpty != null) is the shape isNullOrEmpty uses; there are also returns(), returns(true) and returnsNotNull().
callsInPlace(block, InvocationKind.EXACTLY_ONCE) tells the compiler a lambda runs right here, once, which is why you can assign a val inside run { } and use it after.
require, check, requireNotNull and checkNotNull are built on contracts, which is why require(x != null) narrows x for the rest of the function.
Rules and danger
The contract { } call must be the first statement of a top-level function with a block body, and the API needs @OptIn(ExperimentalContracts::class).
The compiler TRUSTS the contract and never verifies it. A wrong implies hands it a lie, and the resulting NullPointerException lands far from the mistake.
Contracts are worth it for a widely used predicate or a scope-function-like helper, not for a one-off.
A context parameter is a dependency a function declares but nobody passes explicitly: the compiler fills it from a matching context already in scope, so ambient things like a logger or a transaction stop being threaded through every signature.
Declaration: context(logger: Logger) fun sync(). A caller compiles only if a Logger context is available; otherwise the error says the context argument is missing. Use _ as the name if you only need to require the context.
Supplying it: open a scope with with(logger) { sync() }, or declare the same context parameter on the caller and let it propagate.
Several at once, plus a receiver: context(logger: Logger, analytics: Analytics) fun User.deactivate(). Unlike an extension receiver, a context parameter does not take over this.
Scoped capabilities: context(tx: Transaction) fun UserDao.deleteAll() can be called only inside a transaction block; DSLs use the same idea.
Boundaries
It is compile-time resolution, not dependency injection: it replaces parameter threading, not Hilt or Koin, and nothing is looked up in a container.
It replaces the older context receivers, which never became stable; a tutorial showing context(Logger) without a parameter name is out of date.
Context parameters were a preview in Kotlin 2.2 (-Xcontext-parameters) and became stable in Kotlin 2.4; on older compilers the flag is still required. Passing a context explicitly at the call site is still experimental.
Kotlin 2.0 made K2, a rewritten compiler frontend, the default: faster analysis and smarter smart casts with almost no source changes, and the 2.x releases since then use it to ship new language features on a regular cadence.
The compiler
Speed: the frontend analysis is substantially faster, and Android Studio's K2 mode uses the same analysis in the IDE.
Smarter smart casts: they now survive a check stored in a local val, an || of sealed subtypes (cast to their common supertype), and lambdas from inline functions.
Compose compiler: it moved into the Kotlin repository: apply the org.jetbrains.kotlin.plugin.compose Gradle plugin, and its version is the Kotlin version, so the old compatibility table is gone.
Build setup: kotlinOptions { } is deprecated in favour of compilerOptions { }; kapt still works but is slow, and KSP2 is the path forward.
Language features in 2.x
Stable since 2.2: guard conditions in when (is Failure if r.retryable ->), non-local break/continue inside inline lambdas, and multi-dollar interpolation ($$"...").
Stable since 2.3: nested type aliases and data-flow based exhaustiveness checks in when.
Stable since 2.4: context parameters (they replace context receivers) and explicit backing fields (val items: StateFlow<T> with field = MutableStateFlow(...)).
Still experimental or proposed: rich errors (union-style error types), not for production code.
Upgrading in practice
Breakage usually comes from compiler plugins and processors, not your code: bump Compose, KSP, Hilt and the serialization plugin together with Kotlin.