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

> Learn when to use Kotlin sealed for closed hierarchies and exhaustive when expressions. Discover when to use internal to hide module details while allowing subclassing.

- Repository: [JetBrains/kotlin](https://github.com/jetbrains/kotlin)
- Tags: deep-dive
- Published: 2026-02-16

---

**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`](https://github.com/JetBrains/kotlin/blob/main/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`](https://github.com/JetBrains/kotlin/blob/main/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`](https://github.com/JetBrains/kotlin/blob/main/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`](https://github.com/JetBrains/kotlin/blob/main/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`](https://github.com/JetBrains/kotlin/blob/main/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.

```kotlin
// 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`](https://github.com/JetBrains/kotlin/blob/main/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`](https://github.com/JetBrains/kotlin/blob/main/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`](https://github.com/JetBrains/kotlin/blob/main/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`](https://github.com/JetBrains/kotlin/blob/main/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:

```kotlin
// 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`](https://github.com/JetBrains/kotlin/blob/main/compiler/frontend/src/org/jetbrains/kotlin/psi/KtClass.kt)

### Internal Class Visibility

Use `internal` to hide implementation details while allowing extension:

```kotlin
// 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`](https://github.com/JetBrains/kotlin/blob/main/compiler/frontend/src/org/jetbrains/kotlin/resolve/BindingContext.kt)

### Internal Sealed Class

Combine both for internal closed hierarchies:

```kotlin
// 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`](https://github.com/JetBrains/kotlin/blob/main/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`](https://github.com/JetBrains/kotlin/blob/main/DescriptorResolver.kt) (for `sealed` file checks) and [`Modifiers.kt`](https://github.com/JetBrains/kotlin/blob/main/Modifiers.kt) (for `internal` visibility), with type checking performed by [`KotlinTypeChecker.kt`](https://github.com/JetBrains/kotlin/blob/main/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`](https://github.com/JetBrains/kotlin/blob/main/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`](https://github.com/JetBrains/kotlin/blob/main/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.