Fix ClassCastException In Nested GraphExtension Optional Injection
Hey guys, ZacSweers reported an interesting issue related to Dagger/Metro where injecting an java.util.Optional in a child Graph Extension that's bound in the parent Graph Extension can lead to a ClassCastException. Let's dive into the details and see what's going on.
Summary of the Issue
The core problem arises when you try to unwrap the present value of an Optional injected in a child Graph Extension. This setup seems to trigger a ClassCastException, indicating a mismatch between the expected and actual types. Specifically, the error message looks something like this:
java.lang.ClassCastException: class dev.zacsweers.metro.internal.DoubleCheck cannot be cast to class DelegateDependency (dev.zacsweers.metro.internal.DoubleCheck and DelegateDependency are in unnamed module of loader org.jetbrains.kotlin.codegen.GeneratedClassLoader @6414b9f7)
	at DelegateDependencyImpl.<init>(OptionalInNestedGraphExtensionCanBeLoaded.kt:17)
	at DelegateDependencyImpl$$MetroFactory$Companion.newInstance(OptionalInNestedGraphExtensionCanBeLoaded.kt:13)
	at DelegateDependencyImpl$$MetroFactory.invoke(OptionalInNestedGraphExtensionCanBeLoaded.kt:13)
	at DelegateDependencyImpl$$MetroFactory.invoke(OptionalInNestedGraphExtensionCanBeLoaded.kt:13)
	at AppGraph$$MetroGraph$LoggedInGraphImpl$FeatureGraphImpl.getDependency(OptionalInNestedGraphExtensionCanBeLoaded.kt:62)
	at OptionalInNestedGraphExtensionCanBeLoadedKt.box(OptionalInNestedGraphExtensionCanBeLoaded.kt:58)
This error suggests that there's a type mismatch during the injection process, particularly when Dagger tries to resolve the dependency.
Reproducing the Issue
To better understand and address this problem, ZacSweers provided a self-contained reproducer written in Kotlin. This code snippet helps to illustrate the exact scenario where the ClassCastException occurs. Here’s the code:
import java.util.Optional
import kotlin.jvm.optionals.getOrDefault
interface LoggedInScope
interface FeatureScope
interface DelegateDependency
@ContributesBinding(AppScope::class)
class DelegateDependencyImpl @Inject constructor(
    private val appDependency: AppDependency,
    private val LoggedInDependency: Optional<LoggedInDependency>
): DelegateDependency by LoggedInDependency.getOrDefault(appDependency)
interface AppDependency : DelegateDependency
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class AppDependencyImpl @Inject constructor(): AppDependency
interface LoggedInDependency : DelegateDependency
@ContributesBinding(LoggedInScope::class)
@SingleIn(LoggedInScope::class)
class LoggedInDependencyImpl @Inject constructor(): LoggedInDependency
@dagger.Module
@ContributesTo(AppScope::class)
interface DependencyModule {
    @dagger.BindsOptionalOf
    fun provideOptional(): LoggedInDependency
}
@SingleIn(FeatureScope::class)
@GraphExtension(FeatureScope::class)
interface FeatureGraph {
    val dependency: DelegateDependency
}
@SingleIn(LoggedInScope::class)
@GraphExtension(LoggedInScope::class)
interface LoggedInGraph {
    val featureGraph: FeatureGraph
}
@SingleIn(AppScope::class)
@DependencyGraph(AppScope::class)
interface AppGraph {
    val loggedInGraph: LoggedInGraph
}
Explanation of the Code
Let’s break down the code to understand what each part does and how they interact:
- Scopes: 
LoggedInScopeandFeatureScopeare custom scopes used to define the lifecycle of the dependencies. - DelegateDependency: This interface represents a dependency that can be provided by either 
AppDependencyorLoggedInDependency. - DelegateDependencyImpl: This class implements 
DelegateDependencyand injects bothAppDependencyand anOptional<LoggedInDependency>. It usesgetOrDefaultto provide a default value ifLoggedInDependencyis not present. - AppDependency and AppDependencyImpl: These represent a basic application-level dependency.
 - LoggedInDependency and LoggedInDependencyImpl: These represent a dependency available only when the user is logged in.
 - DependencyModule: This Dagger module contributes an optional binding for 
LoggedInDependency. If noLoggedInDependencyis available, theOptionalwill be empty. - Graph Extensions: 
FeatureGraphandLoggedInGraphare graph extensions that define sub-graphs with their own scopes and dependencies. - AppGraph: This is the main dependency graph that includes the 
LoggedInGraph. 
How the Issue Arises
The problem likely stems from how Dagger/Metro handles the Optional injection in the nested graph extension. When DelegateDependencyImpl is instantiated within the FeatureGraph, which is part of the LoggedInGraph, the injection of Optional<LoggedInDependency> might not be correctly resolved, leading to a ClassCastException when the value is unwrapped.
Metro Version
This issue was reported using Metro version 0.7.3. This information is crucial because it helps narrow down the scope of the problem and allows developers to focus on changes or fixes introduced up to that version.
Potential Causes and Solutions
While the exact cause may require deeper investigation, here are some potential reasons and solutions:
- Incorrect Scope Binding: Ensure that all dependencies and their implementations are correctly bound to the appropriate scopes. A mismatch in scope bindings can lead to unexpected behavior during dependency resolution.
 - Dagger/Metro Bug: There might be a bug in Dagger or Metro that causes issues with 
Optionalinjection in nested graph extensions. In this case, consider reporting the issue to the Dagger/Metro team or looking for updates and bug fixes. - Circular Dependency: Although not immediately apparent, a circular dependency could be causing the type mismatch. Review the dependency graph to ensure there are no unintentional cycles.
 - Explicitly Provide Optional: Instead of relying on 
@BindsOptionalOf, try explicitly providing theOptionalinstance. This can sometimes help Dagger resolve the dependency correctly. 
@Module
object DependencyModule {
    @Provides
    fun provideOptionalLoggedInDependency(loggedInDependency: LoggedInDependency?): Optional<LoggedInDependency> {
        return Optional.ofNullable(loggedInDependency)
    }
}
Next Steps
To further investigate this issue, you might want to:
- Debug the Code: Use a debugger to step through the dependency injection process and see where the 
ClassCastExceptionoccurs. - Simplify the Reproducer: Try to simplify the reproducer even further to isolate the exact cause of the problem.
 - Check Dagger/Metro Issues: Look for similar issues reported on the Dagger or Metro issue trackers.
 
Conclusion
The ClassCastException when injecting an Optional from a nested Graph Extension is a tricky issue that requires careful examination of the dependency graph and scope bindings. By understanding the code, potential causes, and possible solutions, you can better tackle this problem and ensure smooth dependency injection in your Dagger/Metro projects. Keep an eye on updates from the Dagger and Metro communities, as they may provide fixes or workarounds for this issue in future releases. Happy coding, guys!