Kotlin fundamentals

Learn Kotlin's read-only references, static types, null safety, expressions, control flow, and function-call boundaries.

level beginner time 12 min at Standard depth
version Kotlin 2.4.10
what

Kotlin is statically typed. It uses val and var to say whether a reference can be reassigned and records nullability in the type.

trap

val doesn’t make an object immutable, and type inference doesn’t add implicit numeric conversions; !! only postpones a null problem until runtime.

fix

Start with val, parse and validate data at input boundaries, and express real alternatives with null checks, ?., ?:, and exhaustive when expressions.

What it is and why it exists

Kotlin’s basic syntax has a simple goal: put program constraints directly in the code. val or var says whether a name can be reassigned, a trailing ? says whether a value may be null, and a branch can produce a result directly. The compiler can then reject many impossible states before the program runs.

Kotlin is statically typed. Every expression has a compile-time type, but local declarations usually don’t repeat it because the compiler performs type inference . Inference doesn’t make the language dynamic: once val count = 3 is inferred as Int, a string can’t later be assigned to count.

These rules appear in every Kotlin program, whether it targets the JVM, JavaScript, Native, or Wasm. This page uses ordinary Kotlin/JVM command-line programs to explain the language core. Android lifecycles, coroutines, collection pipelines, and class design belong to their own topics.

The syntax makes ownership and failure paths easier to read; saving a few characters is incidental. String? tells a caller that absence is part of the contract, while an enum when without else tells a maintainer that a new member will trigger a compile-time check.

How it works

Declarations bind names

val declares a read-only reference: after initialization, that name can’t point at another value. var declares a reassignable reference. A local val may be declared first and assigned once later, but it then needs an explicit type, and the compiler must prove that every path reaches initialization before use.

A read-only reference isn’t the same as an immutable object. val quantities = intArrayOf(1, 2) rejects quantities = intArrayOf(3) but permits quantities[0] = 3, because the array object is still mutable. val constrains the name; the object’s type and API determine whether its state can change.

var fits a loop counter or the current position of a state machine: bindings that genuinely change. Declaring every local as var widens the mutable area and makes readers track more assignment sites. A useful test is whether the code still works after removing reassignment; if it does, the binding should be a val.

Inference still produces a definite type

With an initializer, the compiler can usually infer a local variable’s type. Integer literals normally become Int and become Long when they exceed the Int range. Literals with a decimal point default to Double; an f or F suffix denotes Float. Write the type explicitly when it carries an API contract, permits null, or saves the reader from guessing.

Kotlin’s numeric types don’t convert into one another automatically for assignment or function arguments. To pass an Int to an API that accepts Long, write count.toLong(). An explicit conversion keeps truncation, overflow, and precision changes visible in review.

Mixed numeric expressions can produce a wider result through their operator overloads, but that doesn’t mean every context permits implicit conversion. Integer division deserves particular care: 5 / 2 is 2. At least one operand must be floating point to get 2.5.

Nullability is part of the type

String doesn’t accept null; the nullable type String? does. You can’t access an ordinary member directly on a nullable receiver. Code must first prove it is non-null, use the safe-call operator ?., or provide an alternative through the Elvis operator ?:.

A safe call returns null when its receiver is null, so the result of a chain often remains nullable. If any step in profile?.address?.city is missing, the whole expression produces null. ?: evaluates its right side only when the left side is null, so that right side can be a fallback, return, or throw.

The compiler performs a smart cast when it can prove that a value has a more specific type along one control-flow path. After value == null followed immediately by return, the remaining path can use value as non-null. This proof depends on stability; an open or mutable property that other code may change usually can’t be smart-cast directly.

Branches can produce values

if and when control execution and can also return results as expressions. An if used as a value must cover both branches, and the final expression in each block is that branch’s result. This lets you initialize a val directly instead of declaring a mutable temporary and assigning it in every branch.

when can match values, ranges, or types, and a subjectless when checks Boolean conditions in order. The compiler requires a when used as an expression to be an exhaustive when . Listing every enum or sealed-hierarchy case without a broad else turns a new member into a compile error instead of silently routing it to a default behavior.

Branches are checked in source order, and the first match wins. When ranges overlap, their order is part of the business rule. Put narrow, specific conditions before broad ones so readers don’t have to derive the overlap themselves.

Loops process sequential work

A for loop traverses a value that supplies an iterator, commonly a range, array, or collection. 1..5 includes both endpoints, while 1 until 5 excludes the end. Use downTo for descending progressions and step for an interval. Those boundary differences change the iteration count, and generated code often confuses .. with until.

while checks its condition before each iteration; do-while runs at least once. break exits the nearest loop and continue advances to the next iteration. Labeled jumps can cross nested loops, but they make control flow more expensive to follow. If extracting a function or rewriting a condition removes the label, the result is usually easier to test.

Functions publish contracts

Function parameters always declare types. Block-bodied functions normally declare a return type, especially in public APIs; expression-bodied functions may infer it from the expression. A function with no meaningful result returns Unit, and that return type can usually be omitted.

Default parameters define common behavior at the declaration site, while named arguments show a caller’s choices. Named arguments also allow argument reordering, but names belong to a Kotlin API’s source contract. Kotlin callers can’t rely on Java parameter names to use named arguments with Java methods.

Use require for preconditions. A failed condition throws IllegalArgumentException and places the error on invalid data supplied by the caller. For expected user-input failures, a nullable value or domain result type is often better than an exception. The boundary’s calling contract should decide.

Examples

Start with val and keep mutation narrow

The first program retains one mutable counter, while the customer name, array reference, unit price, and result are read-only bindings. The array’s contents can still change, which demonstrates that val constrains a reference without freezing its object.

references_and_values.kt
fun main() {
    val customer = "Ari"
    val quantities = intArrayOf(2, 1, 3)

    // val blocks reference reassignment, not changes to array elements.
    quantities[1] = 2

    // Only the accumulator needs reassignment.
    var itemCount = 0
    for (quantity in quantities) {
        itemCount += quantity
    }

    val unitPriceCents = 1_250
    val subtotalCents = unitPriceCents * itemCount

    println("Customer: $customer")
    println("Quantities: ${quantities.joinToString()}")
    println("Items: $itemCount")
    println("Subtotal cents: $subtotalCents")
}
Customer: Ari
Quantities: 2, 2, 3
Items: 7
Subtotal cents: 8750

The amount uses integer cents so this basic example doesn’t introduce binary floating-point rounding. 1_250 is still an ordinary Int; underscores only make a numeric literal easier to read. If the product of price and quantity may exceed the Int range, choose Long or a dedicated money type before doing the arithmetic.

Handle null and bad formats at the input boundary

The second program parses a raw string into Int?. Whitespace, an absent value, and nonnumeric text all produce null; the caller then separates “no usable weight” from the shipping bands for valid weights.

nulls_and_branches.kt
fun parseWeight(raw: String?): Int? {
    val normalized = raw?.trim()

    // After the early return, normalized is smart-cast to String.
    if (normalized.isNullOrEmpty()) return null
    return normalized.toIntOrNull()
}

fun shippingBand(weight: Int): String = when {
    weight <= 0 -> "invalid"
    weight <= 10 -> "standard"
    weight <= 30 -> "heavy"
    else -> "freight"
}

fun main() {
    val inputs = listOf(" 8 ", null, "heavy", "42")

    for (raw in inputs) {
        val weight = parseWeight(raw)
        val band = if (weight == null) "missing" else shippingBand(weight)
        println("${raw ?: "<null>"} -> $band")
    }
}
 8  -> standard
<null> -> missing
heavy -> missing
42 -> freight

toIntOrNull() keeps parsing failure in the type instead of relying on exception handling. Both sides of the if produce String, so band is inferred as String. If the business needs to distinguish “absent” from “bad format,” don’t collapse both into the same null; return a type that preserves the separate failure reasons.

Express common calls with default arguments

The third program fixes the unit as cents and uses Long for amounts. Default arguments preserve the common one-item, no-discount call. The bulk order uses named arguments to expose every changed choice.

functions_and_arguments.kt
fun lineTotal(
    priceCents: Long,
    quantity: Int = 1,
    discountPercent: Int = 0,
): Long {
    require(priceCents >= 0)
    require(quantity >= 0)
    require(discountPercent in 0..100)

    val gross = priceCents * quantity
    return gross * (100 - discountPercent) / 100
}

fun main() {
    val regular = lineTotal(priceCents = 2_500)
    val bulk = lineTotal(
        priceCents = 2_500,
        quantity = 4,
        discountPercent = 10,
    )

    println("Regular cents: $regular")
    println("Bulk cents: $bulk")
}
Regular cents: 2500
Bulk cents: 9000

The division here is integer division, so it discards any fraction smaller than one cent after the discount. That isn’t a universally correct financial rounding rule; it is one concrete choice in this function’s contract. Production code should name and test its rounding policy instead of letting operator order decide it silently.

Keep enum branches exhaustive

The final program first converts an untrusted string to Access?, then handles the finite state exhaustively. The parser needs else to catch arbitrary text. The routing function lists null and every enum member without an else.

exhaustive_when.kt
enum class Access {
    GUEST,
    MEMBER,
    ADMIN,
}

fun parseAccess(raw: String): Access? = when (raw.lowercase()) {
    "guest" -> Access.GUEST
    "member" -> Access.MEMBER
    "admin" -> Access.ADMIN
    else -> null
}

fun homeRoute(access: Access?): String = when (access) {
    null -> "/sign-in"
    Access.GUEST -> "/welcome"
    Access.MEMBER -> "/account"
    Access.ADMIN -> "/admin"
}

fun main() {
    for (raw in listOf("member", "ADMIN", "unknown")) {
        val access = parseAccess(raw)
        println("$raw -> ${homeRoute(access)}")
    }
}
member -> /account
ADMIN -> /admin
unknown -> /sign-in

If SUPPORT is added later, homeRoute stops compiling and forces a maintainer to choose the new role’s page. parseAccess accepts an open-ended input set, so it still needs a default branch. The two when expressions look similar but have different boundary contracts.

Pitfalls

Treating val as deep immutability

Fix: Inspect both the binding and the object’s API. Make a copy when you need a snapshot and return a read-only interface when callers shouldn’t mutate through that path. If callers must be able to trust that data never changes, express immutability in the type design instead of only changing the variable to val.

Relying on implicit numeric widening

Fix: Call toLong(), toDouble(), or another conversion explicitly at the semantic boundary, and choose the result type before arithmetic starts. Converting to a narrower integer may change the value, while converting floating point to an integer discards the fractional part, so conversions need boundary tests too.

Using !! to remove a compiler error

Fix: Pick a policy at the input boundary: use ?: return to abandon the operation, ?: throw IllegalArgumentException(...) to report a broken contract, or preserve null in the return type. Reserve a local, explained assertion for the rare case where an external invariant is already established but the compiler can’t express it.

Comparing content with ===

Fix: Use == for value comparison and === only when object identity is genuinely part of the algorithm. Arrays are an extra boundary: array == doesn’t compare elements, so choose contentEquals() or contentDeepEquals() according to the data shape.

Adding a broad else to finite states

Fix: List every case of a closed state so the compiler checks exhaustiveness. Keep else only when the input set is actually open, such as arbitrary strings, integers, or external protocol values, and define whether an unknown input is rejected, ignored, or downgraded.

Hiding side effects inside dense expressions

Fix: Give each expression one result that is easy to name. Validate input first, calculate the value next, and perform side effects last. When a branch needs several steps, expand it with a block and local names instead of optimizing for the fewest lines.

Deep Context sets the boundary of inference

Context sets the boundary of inference

Type inference uses the initializer and the expected type around it. val count = 1 becomes Int, while the same literal in val count: Long = 1 is constrained by the declared type. During resolution, integer literals have a special literal type that the compiler maps to a concrete numeric type allowed by the context and range.

Inference finds a common type that the current expression can prove, not the type future code might need. If two branches return different concrete classes, their result may be inferred as a shared parent type; introducing null usually makes the result nullable. An explicit return type on a public function stops an implementation refactor from accidentally changing the contract callers see.

Generic calls gather constraints from arguments and from the expected return type. A seemingly unrelated explicit type can therefore change type-parameter inference, so don’t repair a type error by adding arbitrary casts. Decide which type the API should expose, then supply a clear expected type through the declaration or function signature.

Smart casts are also inference driven by control flow. The compiler records type checks, null checks, early returns, and relationships between Boolean conditions. If some other code might change the value after the check, the proof no longer holds. Reading a property into a local val often freezes the observed value and restores smart casting at the same time.

Unit and Nothing describe control flow

Unit means a function completes normally but has no meaningful result for its caller. It is a real type with one value, Unit, unlike the complete absence of a value represented by void in some languages. A block-bodied function with no other returned value is normally inferred to return Unit when the annotation is omitted.

Nothing means an expression never produces a normal result. throw has type Nothing, and a function that never returns can declare Nothing. Because Nothing fits other result types, the full expression in val name = input ?: throw IllegalArgumentException(...) can still have a non-null string type.

return is an expression too, so it can sit on the right side of Elvis. val id = parseId(raw) ?: return moves the failure path out of the current function, leaving a non-null id in the remaining code. This guard works for one failure action; if distinct failures need distinct responses, an explicit result type keeps more information than a chain of early returns.

These types explain how branches find a common type. When one branch produces a domain value and another throws, the throwing branch doesn’t force the expression to degrade to Any. The compiler knows that every path which continues normally carries the domain value.

JVM representation is not the source type system

On the JVM target, non-null numbers can usually use JVM primitive representations; nullable numbers and generic positions usually need boxed objects. That implementation detail doesn’t change the source-level rules for Kotlin’s Int, but it affects signatures and nullability at Java interoperability boundaries.

Don’t infer numeric equality from referential equality. The JVM may cache some boxed number objects, making equal small values happen to share a reference while equal larger values occupy separate objects. Use == for numeric meaning. Check identity only when identity itself is a domain concept.

Java declarations often lack the nullability information Kotlin expects. The compiler presents such boundaries as platform types, letting a caller receive the value as nullable or non-null, but a wrong choice can still cause a runtime null-pointer failure. A platform type isn’t an ordinary annotation you can write in Kotlin source; it represents incomplete information at the interoperability boundary.

Narrow a Java return value to an explicit Kotlin type early. val name: String? = javaApi.name() preserves possible absence; when a contract guarantees non-null, validate that at the boundary and report a meaningful failure. Letting platform types travel through business code leaves the same unstated assumption behind every member access.

Equality, arrays, and mutable content

Kotlin translates == into a null-safe equals call. === never calls equals; it only checks reference identity. Whether a class has useful structural equality depends on its equals implementation, and an ordinary class’s default implementation may still amount to identity comparison.

Arrays don’t define == as an element-by-element comparison. Two separate array objects holding the same sequence normally can’t be compared by content with ==. Use contentEquals() for a one-dimensional array and contentDeepEquals() for nested arrays when appropriate. A test with the wrong comparison can reject correct output or fail to inspect elements at all.

A collection’s read-only interface isn’t synonymous with an immutable collection either. A read-only reference may point to an object still held through a mutable alias elsewhere, so later reads can observe changes. Copy the data when an API needs a stable snapshot, and make ownership of the underlying mutable state explicit.

Ask one direct question during review: does the code constrain a name, an interface, a value, or an object’s identity? Mixing the four can survive small examples. The bug appears once data is shared, crosses a Java boundary, or changes state.

Scope, packages, and entry points

Kotlin permits functions, properties, and types at the top level of a file; it doesn’t require every declaration to sit inside a class. Top-level declarations still belong to a package. On the JVM, the compiler generates a class representation to hold them, but Kotlin callers normally don’t need that generated name.

A package declaration belongs at the top of a file, and an import affects name resolution only in that source file. Source directories don’t have to mirror packages mechanically, though builds usually organize them that way for navigation and access review. When imports conflict, as supplies a local alias without scattering fully qualified names across every call.

Braces establish local scopes. An inner declaration may shadow an outer declaration with the same name, but logs and debuggers then make those values hard to distinguish. Generated code reuses names such as result, value, and data aggressively; inside nested blocks, replace them with names that state the unit and state.

Local variables must be initialized before they are read. When a val is assigned inside conditional branches, the compiler checks every path that can continue. Missing one path rejects a later read. Initializing directly from an if or when expression usually makes the “every branch supplies a value” requirement more obvious.

An ordinary command-line application uses a top-level main function as its entry point. It may take no arguments or receive Array<String>. Business logic doesn’t need to live in main; the entry point should turn external strings into explicit types and then call functions that can be tested independently.

File boundaries don’t create access control automatically. A top-level declaration without a modifier is public by default. Mark a helper used only in its file as private, or use internal for module-wide access that shouldn’t be public outside the module. Visibility is an API choice, not cleanup to postpone until library publication.

Review a new basic Kotlin file by checking its boundaries in this order:

  1. Do the package and imports make dependency origins clear?
  2. Do top-level declarations use the narrowest reasonable visibility?
  3. Does the entry point parse and validate at the boundary?
  4. Do business functions receive only values with explicit types and units?

This order separates syntax concerns from architecture concerns. The compiler proves names, types, and initialization. The author still decides which values are trusted, which declarations are public contracts, and how to report a failed operation to the caller.

Further reading

checkpoint

4 questions · 1 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?