When to Use Sealed vs Internal in Kotlin: Controlling Class Visibility and Inheritance

Use sealed when you need a closed type hierarchy with compiler-enforced exhaustive when expressions, and internal when you need to hide implementation details from other modules while allowing free subclassing within the same module.

When designing class hierarchies in Kotlin, choosing between sealed and internal modifiers determines how your code can be extended and accessed across compilation units. While both restrict visibility in some form, they operate on fundamentally different axes: sealed controls inheritance at the file level, whereas internal controls visibility at the module level. Understanding when to use sealed vs internal in Kotlin requires examining how the compiler enforces these constraints in the JetBrains/kotlin repository.

Understanding the Fundamental Difference

The sealed and internal modifiers address distinct architectural concerns. One governs the shape of your type system, while the other governs encapsulation across module boundaries.

What sealed Controls

The sealed modifier restricts the type hierarchy to a closed set of subclasses defined within the same compilation unit. According to the Kotlin compiler implementation in compiler/frontend/src/org/jetbrains/kotlin/psi/KtClass.kt, a sealed class can only be subclassed in the same Kotlin file where it is declared. This enables the compiler to perform exhaustive type checking in when expressions, as implemented in compiler/frontend/src/org/jetbrains/kotlin/types/checker/KotlinTypeChecker.kt.

What internal Controls

The internal modifier restricts visibility to the same module (e.g., a Gradle module or Maven artifact). As defined in compiler/frontend/src/org/jetbrains/kotlin/resolve/Modifiers.kt, internal makes the class visible everywhere within the declaring module but inaccessible to other modules. Unlike sealed, internal places no restrictions on inheritance—subclasses can be declared anywhere within the module, not just in the same file.

When to Choose sealed Over internal

Select sealed when you need compile-time guarantees about the completeness of your type hierarchy and exhaustive pattern matching.

Closed Type Hierarchies and Exhaustive When

Use sealed for representing a finite set of states or algebraic data types where every possible variant is known at compile time. The compiler enforces exhaustive when expressions, warning you if you miss a subclass. This is particularly valuable for result types, AST nodes, or state machine states.

In compiler/frontend/src/org/jetbrains/kotlin/resolve/DescriptorResolver.kt, the compiler validates that all sealed class subclasses reside in the same file, enabling this exhaustive checking.

Binary Compatibility Considerations

Adding a new subclass to a sealed class breaks binary compatibility for callers using exhaustive when without an else branch. The compiler can now see the new type and requires it to be handled. This makes sealed suitable for domains where the set of types is truly closed and unlikely to expand, or where you want compile-time notification of required changes.

When to Choose internal Over sealed

Select internal when you need to hide implementation details while maintaining flexibility for future extension within your module.

Module-Level Encapsulation

Use internal to prevent external modules from depending on your implementation details while allowing free subclassing within the same module. This is ideal for internal APIs, caching layers, or adapter implementations that should not be part of your public contract but need to be extended by other classes in your library.

As tracked in compiler/frontend/src/org/jetbrains/kotlin/resolve/BindingContext.kt, the compiler records visibility modifiers to enforce module boundaries during resolution.

Binary Compatibility and Extensibility

Unlike sealed, adding new subclasses to an internal class does not affect binary compatibility. Callers see only the internal type, not its specific children, allowing you to evolve your internal hierarchy without breaking external consumers. Choose internal when you anticipate adding new implementations or when the hierarchy is an implementation detail rather than a domain model.

Combining sealed and internal

You can combine both modifiers to create an internal sealed class. This restricts subclassing to the same file and limits visibility to the same module. This pattern is useful for internal closed hierarchies that should never leak outside the module, such as internal event types or state representations within a library.

// File: internal/Event.kt
internal sealed class Event {
    object Started : Event()
    data class Data(val payload: String) : Event()
}

Source reference: compiler/frontend/src/org/jetbrains/kotlin/psi/KtClass.kt handles the PSI representation for combined modifiers.

Compiler Implementation Details

The Kotlin compiler enforces these restrictions through distinct mechanisms in the frontend resolution phase.

File-Level vs Module-Level Scope

In compiler/frontend/src/org/jetbrains/kotlin/resolve/DescriptorResolver.kt, the compiler validates that sealed class subclasses are declared in the same Kotlin file, enforcing the file-level scope. Conversely, compiler/frontend/src/org/jetbrains/kotlin/resolve/Modifiers.kt defines internal visibility to map to module boundaries, allowing access across files within the same compilation module.

Runtime Characteristics

Neither modifier incurs runtime overhead. The sealed restriction is enforced entirely at compile-time by compiler/frontend/src/org/jetbrains/kotlin/types/checker/KotlinTypeChecker.kt during type checking of when expressions. The internal modifier compiles to package-private visibility on the JVM (or module-private in the upcoming JVM module system), with no additional runtime checks.

Practical Code Examples

Sealed Class Hierarchy

Use sealed for result types where you need exhaustive handling:

// File: Result.kt
sealed class Result<T> {
    data class Success<T>(val value: T) : Result<T>()
    data class Failure<T>(val error: Throwable) : Result<T>()
}

// Usage – exhaustive when without else branch
fun <T> handle(result: Result<T>) {
    when (result) {
        is Result.Success -> println("Got ${result.value}")
        is Result.Failure -> println("Error ${result.error}")
    }
}

Source reference: compiler/frontend/src/org/jetbrains/kotlin/psi/KtClass.kt

Internal Class Visibility

Use internal to hide implementation details while allowing extension:

// File: internal/Cache.kt
internal open class Cache {
    internal open fun put(key: String, value: Any) { /*…*/ }
    internal open fun get(key: String): Any? = null
}

// File: internal/MemoryCache.kt (same module)
class MemoryCache : Cache() {
    private val map = mutableMapOf<String, Any>()
    override fun put(key: String, value: Any) { map[key] = value }
    override fun get(key: String): Any? = map[key]
}

Source reference: compiler/frontend/src/org/jetbrains/kotlin/resolve/BindingContext.kt

Internal Sealed Class

Combine both for internal closed hierarchies:

// File: internal/Event.kt
internal sealed class Event {
    object Started : Event()
    data class Data(val payload: String) : Event()
}

Source reference: compiler/frontend/src/org/jetbrains/kotlin/psi/KtClass.kt

Summary

  • sealed restricts inheritance to the same file, enabling exhaustive when expressions and closed type hierarchies ideal for domain modeling.
  • internal restricts visibility to the same module, hiding implementation details while permitting flexible subclassing within the module.
  • Binary compatibility differs: adding sealed subclasses breaks exhaustive when checks, while internal subclasses do not affect external callers.
  • Combined usage (internal sealed class) provides maximum encapsulation for internal closed hierarchies.
  • The Kotlin compiler enforces these rules through DescriptorResolver.kt (for sealed file checks) and Modifiers.kt (for internal visibility), with type checking performed by KotlinTypeChecker.kt.

Frequently Asked Questions

Can I use sealed and internal together?

Yes, you can declare an internal sealed class. This combination restricts subclassing to the same file while also limiting visibility to the same module. It is ideal for internal implementation details that require a closed hierarchy, such as internal event types or state machine states that should not be accessible outside the module.

Does sealed affect runtime performance?

No, the sealed modifier has no runtime overhead. The restriction is enforced entirely at compile-time by the Kotlin compiler in compiler/frontend/src/org/jetbrains/kotlin/resolve/DescriptorResolver.kt. The compiler verifies that all subclasses are declared in the same file and uses this information in compiler/frontend/src/org/jetbrains/kotlin/types/checker/KotlinTypeChecker.kt to validate exhaustive when expressions.

Can external modules access sealed class subclasses?

External modules can access sealed class subclasses only if the subclasses themselves are public and the module exports them. However, external modules cannot declare new subclasses of a sealed class, regardless of visibility, because the sealed restriction applies at the file level across all modules. If you need to prevent external access entirely, combine sealed with internal.

What happens if I add a new subclass to a sealed class?

Adding a new subclass to a sealed class is a breaking change for any code using exhaustive when expressions without an else branch. The compiler will now require the new subclass to be handled, causing compilation errors in existing code. This differs from internal classes, where adding new subclasses does not affect binary compatibility because callers interact with the base type rather than specific implementations.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →