Skip to main content
This library is built on Nitro Modules by Marc Rousavy, a high-performance native module system for React Native. This page explains what Nitro Modules are, why they’re used here, and how the architecture delivers zero-overhead version checking.

What are Nitro Modules?

Nitro Modules is a modern alternative to React Native’s legacy bridge-based TurboModules. It uses JSI (JavaScript Interface) to enable direct, synchronous communication between JavaScript and native code without serialization overhead. Key advantages:
  • Zero serialization cost — JavaScript and C++ share memory directly
  • Synchronous native calls — No async overhead for simple operations
  • Type-safe — TypeScript types are generated from specs
  • Cross-platform — Single TypeScript spec generates Swift and Kotlin implementations
Learn more about Nitro Modules at github.com/mrousavy/nitro

Why this library uses Nitro Modules

Performance

Version info (like version, buildNumber, packageName) is read-only metadata. The traditional bridge requires an async call for each value:
// Bridge-based approach (old)
const version = await VersionCheck.getCurrentVersion();
const build = await VersionCheck.getCurrentBuildNumber();
const pkg = await VersionCheck.getPackageName();
With Nitro Modules, these are synchronous property reads:
// Nitro approach (new)
const version = VersionCheck.version;
const build = VersionCheck.buildNumber;
const pkg = VersionCheck.packageName;
No promises, no await, no bridge serialization.

Type safety

The Nitro spec (Version.nitro.ts:1-17) defines the entire API surface in TypeScript. Native implementations are auto-generated from this spec, ensuring type safety across all layers:
export interface VersionCheck
  extends HybridObject<{
    ios: "swift";
    android: "kotlin";
  }> {
  readonly version: string;
  readonly buildNumber: string;
  readonly packageName: string;
  readonly installSource: string | undefined;
  getCountry(): string;
  getStoreUrl(): Promise<string>;
  getLatestVersion(): Promise<string>;
  needsUpdate(): Promise<boolean>;
}
This single spec generates:
  • C++ header and implementation
  • Swift protocol and bridging code
  • Kotlin abstract class and JNI bindings

Zero overhead property access

All properties are cached at module initialization (index.ts:8-12):
const version = HybridVersionCheck.version;
const buildNumber = HybridVersionCheck.buildNumber;
const packageName = HybridVersionCheck.packageName;
const installSource = HybridVersionCheck.installSource;
These values are read once from native and stored as plain JavaScript strings. Subsequent reads have zero native overhead — they’re just JavaScript variable access.

The Hybrid Object pattern

What is a Hybrid Object?

A Hybrid Object is a native object that can be accessed from JavaScript. It exists in native memory (Swift/Kotlin) but exposes properties and methods to JavaScript via JSI.

Architecture flow

Here’s how a property read flows through the system:
JavaScript              C++                    Swift/Kotlin
─────────────────────────────────────────────────────────────
VersionCheck.version → HybridVersionCheckSpec → HybridVersionCheck
                       │                        │
                       │ JSI (zero-copy)        │ Native API
                       │                        │
                       └──────→ string ←────────┘
  1. JavaScript reads the version property
  2. C++ layer (auto-generated from spec) forwards the call via JSI
  3. Native layer (Swift or Kotlin) returns the value from Bundle.main (iOS) or PackageManager (Android)
  4. C++ layer converts the native string to a JSI value
  5. JavaScript receives a plain string
The first read happens at module init. After that, the value is cached in JavaScript memory.

Spec to implementation mapping

TypeScript spec (Version.nitro.ts:8-10):
readonly version: string;
readonly buildNumber: string;
readonly packageName: string;
Generated C++ interface (HybridVersionCheckSpec.hpp:48-52):
virtual std::string getVersion() = 0;
virtual std::string getBuildNumber() = 0;
virtual std::string getPackageName() = 0;
virtual std::optional<std::string> getInstallSource() = 0;
Swift implementation (HybridVersionCheck.swift:12-14):
var version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
var buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
var packageName = Bundle.main.infoDictionary?["CFBundleIdentifier"] as? String ?? "unknown"
Kotlin implementation (HybridVersionCheck.kt:15-24):
override val version = packageInfo?.versionName ?: "unknown"
override val buildNumber = if (android.os.Build.VERSION.SDK_INT >= 28) {
    packageInfo?.longVersionCode.toString()
} else {
    @Suppress("DEPRECATION")
    packageInfo?.versionCode.toString()
}
override val packageName = packageInfo?.packageName ?: "unknown"

Methods: Sync vs async

Synchronous methods

Simple operations that don’t require I/O can be synchronous:
getCountry(): string;  // TypeScript spec
Swift (HybridVersionCheck.swift:26-31):
func getCountry() throws -> String {
    if #available(iOS 16, *) {
        return Locale.current.region?.identifier ?? "unknown"
    }
    return Locale.current.regionCode ?? "unknown"
}
Kotlin (HybridVersionCheck.kt:35-37):
override fun getCountry(): String {
    return java.util.Locale.getDefault().country ?: "unknown"
}
No async/await needed — the call returns immediately.

Asynchronous methods

Network requests and long-running operations return Nitro Promise objects:
getStoreUrl(): Promise<string>;
getLatestVersion(): Promise<string>;
needsUpdate(): Promise<boolean>;
Swift (HybridVersionCheck.swift:33-45):
func getStoreUrl() throws -> Promise<String> {
    return Promise.async {
        let bundleId = Bundle.main.bundleIdentifier ?? ""
        let url = URL(string: "https://itunes.apple.com/lookup?bundleId=\(bundleId)")!
        let (data, _) = try await HybridVersionCheck.session.data(from: url)
        guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
              let results = json["results"] as? [[String: Any]],
              let trackViewUrl = results.first?["trackViewUrl"] as? String else {
            throw NSError(domain: "VersionCheck", code: 1, userInfo: [NSLocalizedDescriptionKey: "App not found on App Store"])
        }
        return trackViewUrl
    }
}
Nitro’s Promise type bridges seamlessly to JavaScript Promise.

Memory and lifecycle

Singleton pattern

The Hybrid Object is created once at module initialization:
const HybridVersionCheck = NitroModules.createHybridObject<VersionCheckType>("VersionCheck");
This creates a single native instance that lives for the app’s lifetime.

Property caching

All readonly properties are eagerly cached at init (index.ts:8-12). This means:
  • First read: JavaScript → JSI → C++ → Swift/Kotlin
  • All subsequent reads: Plain JavaScript variable access (no native call)

Memory footprint

The entire module uses:
  • 1 C++ Hybrid Object
  • 4 cached strings in JavaScript memory
  • Native implementations (Swift/Kotlin classes)
Total overhead: < 1 KB
┌──────────────────────────────────────────────────┐
│ JavaScript Layer (index.ts)                      │
├──────────────────────────────────────────────────┤
│ • Cached properties: version, buildNumber, etc.  │
│ • Wrapper functions: getCountry(), needsUpdate() │
│ • Exported VersionCheck object                   │
└────────────┬─────────────────────────────────────┘
             │ JSI (zero-copy memory)
┌────────────▼─────────────────────────────────────┐
│ C++ Layer (HybridVersionCheckSpec.hpp/.cpp)      │
├──────────────────────────────────────────────────┤
│ • Auto-generated from TypeScript spec            │
│ • Property getters: getVersion(), etc.           │
│ • Method forwarders: getCountry(), etc.          │
│ • Promise bridging                               │
└────────────┬─────────────────────────────────────┘
             │ Platform bridge
      ┌──────┴──────┐
      ▼             ▼
┌─────────────┐ ┌────────────────┐
│ iOS (Swift) │ │ Android (Kt)   │
├─────────────┤ ├────────────────┤
│ Bundle.main │ │ PackageManager │
│ Locale      │ │ Locale         │
│ URLSession  │ │ HttpURLConn    │
└─────────────┘ └────────────────┘

Code generation

Nitro uses a tool called nitrogen to generate native code from TypeScript specs. When you run npx nitro-codegen, it:
  1. Parses Version.nitro.ts
  2. Generates C++ base class (HybridVersionCheckSpec.hpp)
  3. Generates Swift protocol and bridging code
  4. Generates Kotlin abstract class and JNI bindings
  5. Generates TypeScript types for auto-completion
This ensures the JavaScript, C++, Swift, and Kotlin layers are always in sync.

Key takeaways

Zero overhead

Cached properties are plain JavaScript values after first read

Type safe

Single TypeScript spec generates all native code

Synchronous

No async overhead for simple operations like version reads

Cross-platform

One spec, two native implementations (Swift + Kotlin)

Further reading

Build docs developers (and LLMs) love