Skip to content

yandex/implicits

Repository files navigation

Implicits

CI Swift 6.2+ macOS 11+ iOS 14+ License

A Swift library for implicit parameter passing through call stacks. Eliminate parameter drilling and simplify dependency injection with compile-time safety.

Table of Contents

Installation

Add Implicits to your Package.swift:

dependencies: [
    .package(url: "https://github.com/yandex/implicits", from: "1.0.0"),
]

Then add the library to your target:

.target(
    name: "MyApp",
    dependencies: [.product(name: "Implicits", package: "implicits")],
    plugins: [.plugin(name: "ImplicitsAnalysisPlugin", package: "implicits")]
)

The ImplicitsAnalysisPlugin performs static analysis at build time to verify that all implicit parameters are properly provided through the call chain.

The Problem

Consider a simple shopping scenario where we need to pass payment details through multiple function layers:

func goShopping(wallet: Wallet, card: DiscountCard) {
  buyGroceries(wallet: wallet, card: card)
  buyClothes(wallet: wallet, card: card)
  buyCoffee(wallet: wallet, card: card)
}

func buyGroceries(wallet: Wallet, card: DiscountCard) {
  pay(50, wallet: wallet, card: card)
}

func buyClothes(wallet: Wallet, card: DiscountCard) {
  pay(200, wallet: wallet, card: card)
}

func buyCoffee(wallet: Wallet, card: DiscountCard) {
  pay(5, wallet: wallet, card: card)
}

func pay(_ price: Int, wallet: Wallet, card: DiscountCard) {
  wallet.charge(price * (1 - card.discount))
}

This pattern, known as parameter drilling, requires passing the same arguments through every layer of the call stack, even when intermediate functions don't use them directly. In this simple example the savings may seem modest, but imagine dozens of parameters flowing through many layers — the boilerplate adds up quickly.

The Solution

With Implicits, you declare values once and access them anywhere in the call stack:

func goShopping(_ scope: ImplicitScope) {
  buyGroceries(scope)
  buyClothes(scope)
  buyCoffee(scope)
}

func buyGroceries(_ scope: ImplicitScope) { pay(50, scope) }
func buyClothes(_ scope: ImplicitScope) { pay(200, scope) }
func buyCoffee(_ scope: ImplicitScope) { pay(5, scope) }

func pay(_ price: Int, _: ImplicitScope) {
  @Implicit var wallet: Wallet
  @Implicit var card: DiscountCard
  wallet.charge(price * (1 - card.discount))
}

// Usage
let scope = ImplicitScope()
defer { scope.end() }

@Implicit var wallet = Wallet(balance: 500)
@Implicit var card = DiscountCard(discount: 0.1)
goShopping(scope)

Note: Due to Swift's current limitations, a lightweight ImplicitScope object must be passed through the call stack. However, the actual data (wallet, card) doesn't need to be passed — it's accessed implicitly via @Implicit.

Usage Guide

Implicit arguments behave like local variables that are accessible throughout the call stack. They follow standard Swift scoping rules and lifetime management.

Understanding Scopes

Just like regular Swift variables have their lifetime controlled by lexical scope:

do {
  let a = 1
  do {
    let a = "foo" // shadows outer 'a'
    let b = 2
  }
  // 'a' is back to being an integer
  // 'b' is out of scope
}

Implicit variables follow the same pattern, but their scope is managed by ImplicitScope objects. Always use defer to guarantee proper cleanup:

func appDidFinishLaunching() {
  let scope = ImplicitScope()
  defer { scope.end() }

  // Declare dependencies as implicit
  @Implicit
  var network = NetworkService()

  @Implicit
  var database = DatabaseService()

  // Components can now access these dependencies
  @Implicit
  let search = SearchComponent(scope)

  @Implicit
  let feed = FeedComponent(scope)

  @Implicit
  let profile = ProfileComponent(scope)

  let app = App(scope)
  app.start()
}

In this example, we establish a dependency injection container where services are available to all components without explicit passing.

Nested Scopes

Sometimes you need to add local implicit arguments without polluting the parent scope:

class SearchComponent {
  // Access implicit from parent scope
  @Implicit()
  var databaseService: DatabaseService

  init(_ scope: ImplicitScope) {
    // Create a nested scope for local implicits
    let scope = scope.nested()
    defer { scope.end() }

    // This implicit is only available in this scope
    @Implicit
    var imageService = ImageService(scope)

    self.suggestionsService = SuggestionsService(scope)
  }
}

Key points:

  • Use nested() when adding new implicit arguments
  • Parent scope implicits remain accessible
  • Nested implicits don't leak to parent scope

Working with Closures

Closures require special handling to capture implicit context:

class FeedComponent {
  init(_ scope: ImplicitScope) {
    // Using the #implicits macro (recommended)
    self.postFactory = {
      [implicits = #implicits] in
      let scope = ImplicitScope(with: implicits)
      defer { scope.end() }
      return Post(scope)
    }
  }
}

The #implicits macro captures the necessary implicit arguments. The analyzer detects which implicits are needed and generates the appropriate capture list.

Factory Pattern

When creating factory methods that need access to implicit dependencies:

class ProfileComponent {
  // Store implicit context at instance level
  let implicits = #implicits

  @Implicit()
  var networkService: NetworkService

  @Implicit()
  var searchComponent: SearchComponent

  init(_ scope: ImplicitScope) {}

  func makeScreen() -> Screen {
    // Create new scope with stored context
    let scope = ImplicitScope(with: implicits)
    defer { scope.end() }

    return Screen(scope)
  }
}

This pattern allows factory methods to access dependencies available during initialization.

Custom Keys for Multiple Values

By default, Implicits uses the type itself as the key. But what if you need multiple values of the same type?

extension ImplicitsKeys {
  // Define a unique key for a specific Bool variable
  static let guestModeEnabled =
    Key<ObservableVariable<Bool>>()
}

class ProfileComponent {
  let implicits = #implicits

  init(_ scope: ImplicitScope) {}

  func makeProfileUI() -> ProfileUI {
    let scope = ImplicitScope(with: implicits)
    defer { scope.end() }

    // Type-based key (default)
    @Implicit()
    var db: DatabaseService

    // Named key for specific semantic meaning
    @Implicit(\.guestModeEnabled)
    var guestModeEnabled = db.settings.guestModeEnabled

    return ProfileUI(scope)
  }
}

Key Selection Guidelines

Choose your key strategy based on semantics:

// Type key: Only one instance makes sense
@Implicit()
var networkService: NetworkService

// Type key: Singleton service
@Implicit()
var screenManager: ScreenManager

// Named key provides clarity when type would be ambiguous
@Implicit(\.guestModeEnabled)
var guestModeEnabled: ObservableVariable<Bool>

@Implicit(\.darkModeEnabled)
var darkModeEnabled: ObservableVariable<Bool>

Transforming Implicits with map

Need to derive one implicit from another? Use the map function:

class App {
  @Implicit()
  var databaseService: DatabaseService

  init(_ scope: ImplicitScope) {
    let scope = scope.nested()
    defer { scope.end() }

    // Transform DatabaseService → GuestStorage
    Implicit.map(DatabaseService.self, to: \.guestStorage) {
      GuestStorage($0)
    }

    // Now GuestStorage is available as an implicit
    self.guestMode = GuestMode(scope)
  }
}

This is equivalent to manually creating the derived implicit.

Build-Time Analysis

The analyzer tracks implicit dependencies at compile time, generating interface files that propagate through your module dependency graph. This provides type safety and IDE integration.

SPM Plugin Integration

Enable ImplicitsAnalysisPlugin for each target that uses Implicits:

.target(
    name: "MyModule",
    dependencies: [.product(name: "Implicits", package: "implicits")],
    plugins: [.plugin(name: "ImplicitsAnalysisPlugin", package: "implicits")]
)

The plugin generates <Module>.implicitinterface describing which functions require which implicits. This enables cross-module analysis:

ModuleA                        ModuleB (depends on A)
┌─────────────────────┐        ┌──────────────────────┐
│ func fetch(_ scope) │        │ func load(_ scope) { │
│   @Implicit network │        │   fetch(scope)       │
└─────────┬───────────┘        └──────────────────────┘
          │                               ▲
          ▼                               │ reads
   A.implicitinterface ───────────────────┘
   "fetch requires NetworkService"

When ModuleB calls fetch(scope), the analyzer reads A.implicitinterface to discover that fetch requires NetworkService.

Important: All intermediate modules that depend on Implicits must have the plugin enabled:

App → FeatureModule → CoreModule → Implicits
       (plugin ✓)      (plugin ✓)

If any module in the chain is missing the plugin, downstream builds will fail trying to read its non-existent interface file.

Limitations

Since the analyzer works at the syntax level, there are some constraints to be aware of:

1. No Dynamic Dispatch

  • Protocols, closures, and overridable methods can't propagate implicits
  • Use concrete types and final classes where possible

2. Unique Function Names Required

  • Can't have multiple functions with the same name using implicits
  • The analyzer can't resolve overloads

3. Explicit Type Annotations

  • Type inference is limited for type-based keys
  • Named keys include type information
// Type can't be inferred
@Implicit
var networkService = services.network

// Explicit type annotation
@Implicit
var networkService: NetworkService = services.network

// Type inference works with initializers
@Implicit
var networkService = NetworkService()

// Named keys don't need type annotation
@Implicit(\.networkService)
var networkService = services.network

Runtime Debugging

In DEBUG builds, Implicits provides powerful debugging tools to inspect your implicit context at runtime.

Viewing All Implicits

At any breakpoint, add this expression to Xcode's variables view:

ImplicitScope.dumpCurrent()

💡 Tip: Enable "Show in all stack frames" for complete visibility

LLDB Commands

List all available keys:

p ImplicitScope.dumpCurrent().keys

Example output:

([String]) 4 values {
  [0] = "(extension in MyApp):Implicits.ImplicitsKeys._DarkModeEnabledTag"
  [1] = "(extension in MyApp):Implicits.ImplicitsKeys._AnalyticsEnabledTag"
  [2] = "MyApp.NetworkService"
  [3] = "MyApp.DatabaseService"
}

Search for specific implicits (case-insensitive):

p ImplicitScope.dumpCurrent()[like: "network"]

Example output:

([Implicits.ImplicitScope.DebugCollection.Element]) 1 value {
  [0] = {
    key = "MyApp.NetworkService"
    value = <NetworkService instance>
  }
}

Alternatives

Other dependency injection solutions for Swift:

  • swift-dependencies — A dependency management library inspired by SwiftUI's environment
  • needle — Compile-time safe dependency injection for iOS and macOS

Related Concepts

Similar patterns in other languages:

  • Scalagiven/using (formerly implicit parameters)
  • Kotlin — Context receivers

Contributing

See CONTRIBUTING.md for contribution guidelines.

License

Apache 2.0. See LICENSE for details.

About

Clean and lightweight dependency injection for Swift

Resources

License

Contributing

Stars

Watchers

Forks

Packages

No packages published