Kotlin Internal vs Private: Visibility Modifiers Explained with Code Examples
Use private to restrict access to a single file or class, and internal to expose declarations to an entire Kotlin module while hiding them from external consumers.
When architecting Kotlin applications in the JetBrains/kotlin ecosystem, choosing between internal and private visibility modifiers directly impacts your API surface and encapsulation boundaries. Understanding how the compiler enforces these rules—down to the JVM bytecode level—helps you write maintainable libraries and applications that leverage kotlin internal vs private scoping effectively.
Understanding Visibility Scope in Kotlin
Kotlin’s visibility modifiers create distinct boundaries that determine where a declaration can be accessed. The compiler implements these checks in core/compiler.common/src/org/jetbrains/kotlin/descriptors/Visibilities.kt, where each modifier is defined as a concrete object extending the base Visibility class.
Private Visibility: File and Class Boundaries
The private modifier enforces the strictest encapsulation. For top-level declarations (functions, properties, or classes declared directly in a file), private restricts access to that specific file only. For members of a class or interface, private restricts access to the body of that class, including its companion objects.
According to the Kotlin compiler source, object Private in Visibilities.kt implements Visibility("private", isPublicAPI = false) with mustCheckInImports() returning true, forcing the compiler to verify visibility across import statements.
Internal Visibility: Module-Level Access
The internal modifier exposes declarations to the entire Kotlin module—defined as a set of Kotlin files compiled together, such as a Gradle :lib project or a Maven module. While internal members are accessible from any file within the same module, they remain invisible to other modules that depend on it.
In Visibilities.kt, object Internal implements Visibility("internal", isPublicAPI = false) and also requires import checking. This modifier strikes a balance between encapsulation and internal reusability, allowing you to expose implementation helpers to the rest of your module without polluting your public API.
Compiler Implementation: How Kotlin Enforces Visibility
The Kotlin compiler enforces visibility through a combination of descriptor objects, comparison logic, and JVM bytecode generation strategies.
Visibility Objects in Visibilities.kt
The concrete implementations in core/compiler.common/src/org/jetbrains/kotlin/descriptors/Visibilities.kt define the behavior of each modifier:
// core/compiler.common/src/org/jetbrains/kotlin/descriptors/Visibilities.kt
object Private : Visibility("private", isPublicAPI = false) {
override fun mustCheckInImports(): Boolean = true
}
object Internal : Visibility("internal", isPublicAPI = false) {
override fun mustCheckInImports(): Boolean = true
}
Both modifiers force the compiler to check imports, but they differ in their scope resolution logic elsewhere in the compiler pipeline.
Visibility Comparison and Ordering
The compiler ranks visibility strength using ORDERED_VISIBILITIES in Visibilities.kt, which assigns numeric values to each modifier:
PrivateandPrivateToThis: 0 (most restrictive)InternalandProtected: 1 (module or inheritance scope)Public: 2 (least restrictive)
This ordering determines visibility conflicts during overload resolution and inheritance checking. When comparing kotlin internal vs private, private is strictly more restrictive than internal.
JVM Bytecode Generation
At the bytecode level, the modifiers translate differently:
privatemembers compile to private JVM members (or package-private for top-level private declarations), enforcing restrictions at the JVM level.internalmembers compile to public JVM members but receive a synthetic module-name suffix (e.g.,myFunction$module_name). The Kotlin compiler emits metadata that causes other modules to reject these calls at compile time, while the JVM sees them as public.
This implementation detail explains why internal APIs can appear public in Java interop scenarios unless marked with @PublishedApi or similar mechanisms.
Practical Code Examples
Consider a multi-module Gradle project with a :lib module and an :app module.
File: lib/src/main/kotlin/com/example/utils.kt
internal class Helper { // visible throughout the `lib` module
fun assist() = "assist"
}
private fun secret() = "shh" // only this file can call it
File: lib/src/main/kotlin/com/example/api.kt
fun useHelper() {
// OK: same module, internal class is accessible
val h = Helper()
println(h.assist())
// println(secret()) // compile error: `secret` is private to utils.kt
}
File: app/build.gradle.kts (depends on :lib)
dependencies {
implementation(project(":lib"))
}
File: app/src/main/kotlin/com/example/App.kt
fun main() {
// ERROR – `Helper` is internal to the `lib` module, not visible here
// val h = Helper() // compile-time error
// `secret()` is also inaccessible (and invisible) from this module
}
Summary
privaterestricts access to the containing file (top-level) or class (members), providing the strongest encapsulation within the Kotlin compiler's visibility hierarchy (rank 0).internalexposes declarations to the entire Kotlin module while hiding them from external modules, striking a balance between reusability and API hygiene (rank 1).- The compiler enforces these rules through
Visibilities.ktandVisibility.kt, generating JVM bytecode that makesprivatemembers strictly private while using synthetic naming and metadata to enforceinternalboundaries. - Choose
privatefor implementation details that should never leak, andinternalfor module-wide utilities that support your public API without exposing them to consumers.
Frequently Asked Questions
Can Java code access Kotlin internal members?
No. While internal members compile to public JVM bytecode with synthetic module-name suffixes, the Kotlin compiler emits metadata that prevents other modules from accessing them. Java code in a different module will not see these members as accessible, though they may appear visible in the bytecode. Java code within the same module can access internal members because the compiler treats them as part of the same compilation unit.
Why does the Kotlin compiler rank private as more restrictive than internal?
The compiler uses an ordered visibility map in Visibilities.kt where private receives rank 0 and internal receives rank 1. This ranking reflects the scope size: private limits access to a single file or class, while internal expands access to the entire module. During overload resolution and visibility checking, the compiler uses these ranks to determine if a candidate declaration is visible from a given context, ensuring that private always takes precedence as the most restrictive modifier.
When should I use internal instead of private for top-level declarations?
Use internal when you need to share implementation details across multiple files within the same module, such as utility functions, extension functions, or helper classes that support your public API but should not be exposed to module consumers. Use private for top-level declarations that are strictly local to a single file, such as file-private constants or helper functions that only one class or function in that file requires. If other files in the module need the declaration, internal is the correct choice; if not, prefer private for stronger encapsulation.
How does internal visibility affect binary compatibility?
Changing internal members requires recompilation of dependent modules because the compiler embeds module-specific metadata and synthetic naming (such as the $module_name suffix) into the bytecode. While internal members are not part of the public API contract, modifications to them can break compilation for other modules within your project that reference these members. In contrast, private members can be changed freely without affecting any other compilation units, as they are completely invisible outside their containing file or class. Treat internal APIs as stable within your module ecosystem to avoid unnecessary recompilation cascades.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →