Visibility Modifiers

Every class member and top-level declaration in Kotlin carries a visibility modifier — a rule for exactly who is allowed to see and use it. The four modifiers, public, private, protected, and internal, let you draw a hard line between a type’s public API and the implementation details behind it, so callers can’t reach in and mutate state they have no business touching. Kotlin’s rules differ from Java’s in a few important ways: the default visibility is public rather than Java’s package-private, and internal introduces a module-scoped visibility that Java has no direct equivalent for. This lesson covers all four modifiers, how they apply differently to top-level declarations versus class members, and the mistakes that catch people moving from Java or from looser scripting languages.

Overview: How Visibility Modifiers Work

Kotlin checks visibility entirely at compile time. When you write obj.member, the compiler looks at where that access happens and compares it against the declared visibility of member. If the access site isn’t allowed to see it, compilation fails immediately with an “is private/protected/internal in” error — there’s no way to bypass this at runtime in ordinary code (reflection can, but that deliberately breaks the encapsulation contract and should be avoided).

The four modifiers are:

  • public — visible everywhere. This is the default: if you omit a modifier, the declaration is public. That’s a deliberate departure from Java, where a member with no modifier is package-private.
  • private — visible only inside the class (for a member) or only inside the file (for a top-level declaration). A private class member cannot be accessed even from another class in the same package, and a private top-level function is invisible outside the .kt file that declares it.
  • protected — like private, but also visible to subclasses. protected only makes sense on class members; it cannot be applied to top-level declarations because there’s no inheritance relationship to extend at file scope.
  • internal — visible anywhere inside the same module, but not outside it. A module means a set of Kotlin files compiled together — a Gradle source set, a Maven project, or an IntelliJ module. Code in a separate module (a library you depend on, or an app that depends on your library) cannot see internal declarations even though every file inside your own module can.

Visibility applies to classes, interfaces, objects, functions, properties, and constructors. It does not apply to local variables or local functions declared inside a function body — those are always only visible within their enclosing block, so a modifier there would be meaningless and the compiler rejects it.

Top-level declarations vs. class members

A top-level declaration (written directly in a file, not nested in a class) has only three usable visibilities: public (default), private (file-scoped), and internal (module-scoped). protected is not legal at the top level. A class member can use all four, and private means something narrower there: visible only inside that class’s own body (including its nested classes), not the whole file.

Constructor visibility

The primary constructor’s visibility is public by default, same as everything else. To restrict it you must add the explicit constructor keyword, because a modifier can’t attach directly to the class header without it: class User private constructor(val name: String). This is exactly how you force construction through a factory function or a companion object — a common, idiomatic pattern shown below.

Syntax

// on a class member
class ClassName {
    public val a: Int = 1      // visible everywhere (default, rarely written explicitly)
    private val b: Int = 2     // visible only inside ClassName
    protected val c: Int = 3   // visible inside ClassName and its subclasses
    internal val d: Int = 4    // visible anywhere in the same module
}

// on a top-level declaration
public fun topLevelFun() {}     // visible everywhere (default)
private fun fileOnlyFun() {}    // visible only in this file
internal fun moduleOnlyFun() {} // visible anywhere in this module

// on a primary constructor - the 'constructor' keyword becomes mandatory
class Restricted private constructor(val id: Int)
  • Modifier position — placed directly before the declaration (class, fun, val/var, or constructor).
  • No modifier — means public; Kotlin never defaults to package-private the way Java does.
  • protected restriction — legal only on class/interface members, never on a top-level or local declaration.
  • Module boundary for internal — determined by the build system (a Gradle module, a Maven artifact, an IntelliJ module), not by package or folder.

Examples

Example 1: private state behind a public API

The most common use of visibility is hiding mutable internal state behind a small, safe public surface. Here balance can only change through deposit, so BankAccount controls every mutation.

class BankAccount(private val owner: String, initialBalance: Int) {
    private var balance: Int = initialBalance

    fun deposit(amount: Int) {
        balance += amount
    }

    fun getBalance(): Int = balance

    fun describe(): String = "$owner has balance $balance"
}

fun main() {
    val account = BankAccount("Alice", 100)
    account.deposit(50)
    println(account.describe())
}

Output:

Alice has balance 150

Both owner and balance are private, so code outside BankAccount — including main — cannot read or write them directly; it can only go through deposit, getBalance, and describe. Note that initialBalance, the constructor parameter, has no modifier and isn’t declared val/var, so it isn’t a property at all — it’s a plain parameter used once to initialize balance, and it doesn’t exist as a member afterward.

Example 2: protected members shared with subclasses

protected is the middle ground between private and public: hidden from the outside world, but available to anything that extends the class.

open class Animal(protected val name: String) {
    protected open fun sound(): String = "..."
}

class Dog(name: String) : Animal(name) {
    override fun sound(): String = "Woof"

    fun describe(): String = "$name says ${sound()}"
}

fun main() {
    val dog = Dog("Rex")
    println(dog.describe())
}

Output:

Rex says Woof

Dog can read the inherited name property and call the overridden sound() because both are protected in Animal, and Dog is a subclass. Code in main could not write dog.name or call dog.sound() directly — the compiler would reject it with “cannot access … it is protected in ‘Animal'”. Note also that Animal is declared open; Kotlin classes and members are final by default, so both the class and sound() need open before they can be inherited or overridden at all.

Example 3: a private constructor forcing controlled creation

Marking the primary constructor private is a common way to guarantee every instance is built through validated logic, typically a companion object factory function.

class User private constructor(val username: String) {
    companion object {
        fun create(username: String): User? {
            return if (username.isNotBlank()) User(username) else null
        }
    }
}

fun main() {
    val user = User.create("kotlin_dev")
    if (user != null) {
        println("Created user: ${user.username}")
    } else {
        println("Invalid username")
    }
}

Output:

Created user: kotlin_dev

User(...) cannot be called from outside User itself — the private constructor is only reachable from inside the class body, which is exactly where the companion object’s code runs. create returns User?, a nullable type, so every caller is forced by the compiler to handle the “invalid username” case with a null check before touching username; there is no way to end up with a half-valid User.

Example 4: internal visibility across a module

internal sits between private and public: open to every file compiled in the same module, closed to anything outside it.

internal class Repository {
    internal fun fetch(): String = "data from repository"
}

fun main() {
    val repo = Repository()
    println(repo.fetch())
}

Output:

data from repository

Inside this module, Repository and fetch() behave like ordinary public members — any file in the same compilation unit can use them, as main does here. The restriction only shows up at the module boundary: if this file were compiled into a library and another Gradle module or app depended on that library’s compiled output, it would not see Repository at all — no import would resolve it. This makes internal the right choice for implementation types a library needs to share across its own files without exposing them as part of its public API.

How It Works Step by Step

Take Example 1 and trace what the compiler checks at each line:

  1. class BankAccount(private val owner: String, initialBalance: Int) — the compiler records that owner is a private property of BankAccount, visible only within the class body.
  2. private var balance: Int = initialBalance — another private property; because it’s var, it’s mutable, but only from inside the class.
  3. fun deposit(amount: Int) { balance += amount } — a member function, so it’s inside the class body and is allowed to read and write balance directly.
  4. In main, account.deposit(50) compiles because deposit has no modifier, so it defaults to public and is callable from anywhere, including a different file.
  5. If main instead tried account.balance += 50, the compiler would resolve balance‘s declaration, see it’s private, see that main is outside BankAccount‘s body, and reject the whole file before any bytecode is generated — visibility failures are always compile errors, never something that surfaces later at runtime.
  6. describe() is a member function, so even though it’s called from outside (in main), the string template "$owner has balance $balance" inside it executes in a context that is inside the class, so it’s allowed to read both private properties.

The same reasoning extends to protected (step 5 just adds “or a subclass of the declaring class” to the allowed set) and to internal (the allowed set becomes “anywhere in the same module” instead of “anywhere”).

Common Mistakes

Mistake 1: trying to reach a private property from outside the class

Wrong — this treats balance as if it were public:

class Wallet {
    private var balance: Int = 0
}

fun main() {
    val wallet = Wallet()
    wallet.balance = 100
    println(wallet.balance)
}

This fails to compile with cannot access 'balance': it is private in 'Wallet' on both the assignment and the read — private class members are invisible everywhere outside the class body, including main in the same file.

Corrected — expose controlled access through public methods instead:

class Wallet {
    private var balance: Int = 0

    fun addFunds(amount: Int) {
        balance += amount
    }

    fun getBalance(): Int = balance
}

fun main() {
    val wallet = Wallet()
    wallet.addFunds(100)
    println(wallet.getBalance())
}

Output:

100

Mistake 2: assuming protected means “accessible from any related class”

protected only extends visibility to subclasses — not to other classes in the same file or package, however related they might seem:

open class Vehicle {
    protected val maxSpeed: Int = 120
}

class Inspector {
    fun checkSpeed(vehicle: Vehicle): Boolean {
        return vehicle.maxSpeed > 100
    }
}

Inspector does not extend Vehicle, so this fails with cannot access 'maxSpeed': it is protected in 'Vehicle', even though both classes live in the same file.

Corrected — either make the caller a subclass, or add a public method that exposes exactly what’s needed:

open class Vehicle {
    protected val maxSpeed: Int = 120

    fun isFast(): Boolean = maxSpeed > 100
}

fun main() {
    val vehicle = Vehicle()
    println(vehicle.isFast())
}

Output:

true

Mistake 3: assuming no modifier means package-private, like Java

Java developers often add no modifier expecting package-level visibility. In Kotlin, no modifier means public — visible from any file in any module that depends on yours. Kotlin doesn’t organize visibility around packages at all; if you want something hidden outside a module, you must write internal explicitly, and if you want it hidden outside a single file, you must write private explicitly. Leaving a declaration unmarked when you intended it as an implementation detail is one of the easiest ways to accidentally leak internals as part of a library’s public API.

Best Practices

  • Default to the narrowest visibility that works, then widen only when a real caller needs it — it’s easier to loosen a restriction later than to tighten a public API without breaking callers.
  • Keep mutable state (var properties) private and expose reads/writes through functions or a computed val, so the class controls every change instead of trusting callers.
  • Use internal for types and functions shared across files in a library or app module that shouldn’t appear in that module’s public API surface.
  • Reach for a private constructor plus a companion object factory function whenever construction needs validation, caching, or the option to return null instead of always building a new object.
  • Remember protected only helps inheritance-based designs; if you’re not designing a class to be subclassed, prefer private and a public method instead.
  • Don’t rely on visibility as a security boundary — it stops accidental misuse from other Kotlin code, not a determined caller using reflection.

Practice Exercises

  • Write a Temperature class with a private celsius: Double property, a public toFahrenheit(): Double function, and a public describe(): String that prints both values. Confirm the caller cannot read celsius directly.
  • Write an open Shape class with a protected val for a numeric dimension and an open function area(): Double. Create two subclasses (e.g. Square and Circle) that override area() using the protected property, and print both areas from main.
  • Write a Ticket class with a private constructor and a companion object create(seat: Int): Ticket? factory that returns null for a seat number below 1. Call it twice from main — once with a valid seat and once with an invalid one — and print the outcome of each using a null check.

Summary

  • public is Kotlin’s default — no modifier means visible everywhere, unlike Java’s package-private default.
  • private means “this file” for top-level declarations and “this class body” for members.
  • protected extends private-like visibility to subclasses, and only applies to class/interface members, never to top-level declarations.
  • internal means visible anywhere in the same compiled module, but invisible to code outside it — useful for implementation details shared across a library’s own files.
  • Visibility is enforced entirely at compile time; there is no runtime bypass in ordinary code.
  • A primary constructor needs the explicit constructor keyword before it can carry a visibility modifier, e.g. private constructor(...), commonly paired with a companion object factory function.