Schema & Generated Types

How the server-owned schema reaches your app at runtime, and how voidhash-cli turns it into typed slugs.

Your schema — products, paywall locations, and perks — is authored in the Voidhash dashboard. The server is the single source of truth; there is no local schema file to write or push. The SDK consumes the schema in two ways:

  • At runtime, the client fetches the schema from the server when the Provider mounts and uses it to resolve product slugs to native store product IDs, look up paywall locations, and map perks.
  • At compile time, voidhash-cli types generate writes a voidhash.gen.d.ts declaration file so slugs like "monthly_sub" autocomplete and typecheck in your editor.

Both views of the schema carry the same server-computed version hash, which is what keeps them honest with each other.

The runtime schema

client.init() (called for you by <voidhash.Provider>) fetches the runtime schema with a stale-while-revalidate cache:

  • Warm cache: the cached schema is returned immediately and a background refresh updates the cache for the next launch. The cache lives for 30 days, so long offline gaps are fine.
  • Cold cache: the fetch is synchronous, and if the server is unreachable it is fatal — init() fails with FailedToFetchSchemaError. In practice this only happens on the very first launch (or first launch of a new app version) without connectivity.

The cache is keyed by your app version. A new build may reference products or locations that did not exist when an older schema was cached, so the first launch of every new build forces a fresh fetch instead of serving a potentially incompatible cached schema.

The fetched schema contains slug-keyed records of products, locations, and perks, plus the version hash:

interface RuntimeSchema {
  version: string; // sha256 hash of the schema state on the server
  products: Record<string, RuntimeProductDefinition>;
  locations: Record<string, RuntimePaywallLocationDefinition>;
  perks: Record<string, RuntimePerkDefinition>;
}

Each product carries its type ("subscription", "one-time", or "one-time-consumable"), a display name, the perks it grants, and its per-store configuration (appleAppStore.productId, googlePlay.productId + optional basePlanId).

Schema changes ship without an app release

Because the schema is fetched at runtime, renaming or removing a slug in the dashboard affects apps already in production. Slugs referenced by shipped app versions should be treated as a stable contract — add new ones freely, but retire old ones carefully.

Generating types

Slug type-safety comes from a generated declaration file, not from the runtime fetch. Run this in your project root (it requires being logged in and a voidhash.config.ts — see the CLI types command):

npx voidhash-cli types generate

This fetches the schema from the server and writes voidhash.gen.d.ts:

voidhash.gen.d.ts
// voidhash.gen.d.ts — generated by voidhash-cli, do not edit
// @voidhash:version 3f6c1a9e2b74d0c8e5a1...
// @voidhash:fetched-at 2026-07-17T09:30:00.000Z

declare module "@voidhash/react-native" {
  interface VoidhashRegister {
    schema: {
      products: "monthly_sub" | "yearly_sub";
      locations: "onboarding" | "settings-upsell";
      perks: "all-access" | "premium-features";
    };
  }
}

export {};

The output path defaults to voidhash.gen.d.ts at the project root and can be changed via typesOutput in your config file:

voidhash.config.ts
import { defineConfig } from "voidhash-cli";

export default defineConfig({
  team: "my-team",
  project: "my-app",
  typesOutput: "src/voidhash.gen.d.ts", // optional, defaults to voidhash.gen.d.ts
});

Commit the generated file

voidhash.gen.d.ts belongs in version control. It is what makes types reproducible across your team and in CI — and voidhash-cli types check exists precisely to catch a committed file that has fallen behind the server.

How the augmentation works

The SDK exports an intentionally empty interface, VoidhashRegister, that the generated file augments via declaration merging. Three types resolve against it:

  • ProductSlug — literal union of product slugs
  • LocationSlug — literal union of paywall location slugs
  • PerkSlug — literal union of perk slugs

Every typed surface of the SDK uses these, so once the .d.ts is part of your TypeScript compilation, slug arguments autocomplete and invalid slugs are compile errors:

const paywall = voidhash.usePaywallByLocation("onboarding"); // ✓ autocompletes
const oops = voidhash.usePaywallByLocation("onbaording"); // ✗ compile error

When no augmentation is present — before the first generate, or in isolated tests — the types degrade gracefully to string. The same applies when the dashboard schema is still empty: the generated file declares an empty union, and the SDK falls back to string so your code keeps compiling. Nothing about the runtime depends on the generated file; it is purely a compile-time aid.

Watch mode during development

When someone edits the schema in the dashboard while you are coding, your generated types drift. Watch mode keeps them fresh by polling the server's GET /schema/version endpoint and regenerating only when the version hash changes:

npx voidhash-cli types generate --watch --poll-interval-ms 5000

Poll failures and regeneration failures are logged as warnings and skipped — the loop keeps running, and you keep working against the last-known-good declaration file.

Metro plugin

Rather than remembering to run the watcher in a second terminal, wire it into Metro so it runs alongside expo start:

metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const { withVoidhash } = require("@voidhash/react-native/metro");

module.exports = withVoidhash(getDefaultConfig(__dirname), {
  pollIntervalMs: 5000, // optional
});

withVoidhash(metroConfig, options?) spawns voidhash-cli types generate --watch as a sibling child process when the Metro server starts and tears it down on shutdown. It returns your Metro config unchanged — nothing is injected into Metro's transformer or resolver pipeline.

Options:

interface WithVoidhashOptions {
  /** How often to poll the server for schema changes (ms). Default 5000. */
  pollIntervalMs?: number;
  /** Path to the voidhash-cli binary. Defaults to picking it up via $PATH. */
  cliBinary?: string;
  /** Additional CLI arguments forwarded after `types generate --watch`. */
  extraArgs?: ReadonlyArray<string>;
}

voidhash-cli is an optional peer dependency of the SDK. If the binary is not installed, withVoidhash logs a warning and returns the config unchanged instead of crashing Metro. Errors from the spawned CLI (for example, being logged out) are also logged without crashing Metro.

The version hash and CI

Every generated file starts with a // @voidhash:version <hash> header — the sha256 hash of the schema state on the server, supplied by the server itself so client and server can never disagree on it. This header is what watch mode compares against GET /schema/version, and what the CI gate checks:

npx voidhash-cli types check
  • In sync: prints ✓ Types are up to date (version ...) and exits 0.
  • Stale: prints ✗ Types are stale. with the local and server versions plus the remediation (Run 'voidhash-cli types generate' to refresh, then commit the updated declaration file.), and exits non-zero.

Add it to your PR pipeline so a schema change in the dashboard cannot silently invalidate the committed types: if someone renames a product slug on the server, the next PR fails types check until the declaration file is regenerated and committed — at which point TypeScript surfaces every call site still using the old slug.

The command also fails with distinct messages when the declaration file is missing (run voidhash-cli types generate first) or when its version header has been stripped by hand-editing.