# Instrumentation Workflow with Generated Clients


{{< announcement >}}
This guide describes a workflow built on [RudderTyper v2]({{< ref "dev-tools/rudder-cli/ruddertyper-v2.md" >}}) and [Local Generation]({{< ref "dev-tools/rudder-cli/ruddertyper-v2-local-generation.md" >}}), both in **Public Beta**.
{{< /announcement >}}

Generating a typed client is one command. Running that client as the contract for a real application — across two repositories, a team, and a review process — is a workflow. This guide describes the one RudderStack uses internally to instrument its own web application, and the specific failure modes that shaped it.

## The shape of it

Two repositories, in a fixed relationship:

```text
  catalog repository                     application repository
  ──────────────────                     ──────────────────────
  data-catalog/
    events/         ──┐
    properties/       │   rudder-cli      src/analytics/generated/   (generated,
    custom-types/     ├─  typer      ──▶    index.ts                  committed,
  tracking-plans/   ──┘   generate          SOURCE.md                 never edited)
    app.yaml              --local
                                         src/analytics/client.ts     (written once)
                                         src/**                      (call sites)
```

The Tracking Plan is the source of truth. The client is a build artifact of it. Application code consumes the artifact and never defines an event shape of its own.

This has one consequence worth stating plainly: **you cannot add a new event from the application repository alone.** An application pull request that invents an event without a matching catalog change is incomplete by construction.

{{< info >}}
If your Tracking Plan lives in the same repository as your application, everything below still applies, but the merge-order and provenance sections collapse into "both sides land in one commit". That is the simpler setup — prefer it when you have only one consumer.
{{< /info >}}

## The loop

Steps 1 to 4 are entirely local. Generation publishes nothing and touches no workspace, so iterate freely until the call sites need no adapters. Only then open pull requests.

1. **Edit the catalog.** Define the event, its properties, and the Tracking Plan rule binding them, branched off the catalog's current default branch.
2. **Regenerate.** Run your sync script. The types update straight from those local edits.
3. **Add the call sites and compile.** The generated types are now the contract, and the compiler lists every call site that does not satisfy it.
4. **If the types fight you, go back to step 1.** Reshape the catalog. Do not reshape the call site.
5. **Verify in the running application.** Fire the event and confirm the payload.

{{< warning >}}
**Never add a type cast, a `?? ''`, or a sentinel value such as `'none'` to make a payload satisfy a generated type.** Doing so does not solve a problem; it suppresses a finding.
{{< /warning >}}

### Why casting is the wrong instinct

When RudderStack migrated its own web application onto a generated client, the application immediately failed to compile. The stricter types were not the problem — they were the first thing in years to notice a real one.

Two `identify` traits were marked `required: true` in the Tracking Plan and were absent on roughly 40% of production `identify` calls. The cause was a hydration race: the application fired `identify` on login before its billing store had loaded, then fired it again once the store hydrated. 94% of users had both populated and empty `identify` calls.

The previous generator had leaked `| undefined` into required fields, so the mismatch compiled and shipped for years. Every layer that could have caught it was absent or broken. The analyses built on those traits were quietly wrong: a funnel segmented by plan at event time silently dropped or mis-bucketed about 40% of its rows.

The fix was to correct the contract — one trait was genuinely optional and was remodeled as such; the other needed an application fix. A cast at the call site would have restored the compile and preserved the bug.

**A type error from a generated client is usually a finding about the Tracking Plan, not an obstacle.** If a property is not always available, mark it `required: false` and let it be optional in the generated type.

## Constructing the client

For TypeScript, construct the client **exactly once**, in one module, and export that instance.

```ts
// src/analytics/client.ts
import type { RudderAnalytics } from '@rudderstack/analytics-js';
import { RudderTyper } from './generated';

export const analytics = new RudderTyper(() => window.rudderanalytics as RudderAnalytics);
```

The constructor takes a **resolver function, not an SDK instance**, and this is not a style preference.

The standard [JavaScript SDK snippet]({{< ref "sources/event-streams/sdks/rudderstack-javascript-sdk/" >}}) installs a buffering preloader on `window.rudderanalytics` immediately, then replaces it with the real SDK once the script loads asynchronously. A client constructed at import time — before that swap — that captured the instance would hold the preloader forever. **Every event fired after the SDK loaded would go into an abandoned queue.** No error, no type error, no failed build. Events simply stop arriving.

Because the resolver is re-invoked on every call, the swap is picked up automatically. From Rudder CLI v0.22.0 the constructor is resolver-only, so the unsafe form does not compile:

```ts
new RudderTyper(window.rudderanalytics);
// error TS2345: Argument of type 'RudderAnalytics' is not assignable
// to parameter of type '() => RudderAnalytics'.
```

{{< info >}}
Kotlin and Swift clients take an SDK instance rather than a resolver, because mobile applications construct the SDK themselves and there is no asynchronous swap to survive. Do not carry the resolver pattern across platforms.
{{< /info >}}

Two more rules for the construction point:

- **Handle an absent SDK.** The generated code calls `analytics.track(...)` without guarding, so when no write key is configured and `window.rudderanalytics` is undefined, firing an event throws. Substitute a no-op stub in your resolver.
- **Never let analytics change behavior.** Wrap `track` calls in `try {} catch {}` whenever they sit inside a `try` whose `catch` shows the user an error. Otherwise a blocked or missing SDK turns a successful user action into a reported failure. For the same reason, never fetch data to enrich a payload — use what the page already loaded.

## Provenance

Because the types come from specs on disk, a stale or wrong-branch catalog checkout produces a different, valid-looking client that compiles and passes tests. This is the easiest way to ship a wrong client, and the catalog path usually defaults to a directory that exists and is often on some other branch.

Have your sync script write a `SOURCE.md` next to the generated client, recording the catalog commit, the branch, and whether the working tree was clean. Commit it alongside `index.ts`, and check it before committing:

- Does the commit match the catalog change you actually made? Good.
- Does it say the working tree was dirty? The client is not reproducible from any commit — commit the catalog first, then regenerate.
- Anything else? You generated from the wrong checkout. Regenerate.

**When reviewing either pull request, check that `SOURCE.md` points at the paired catalog change.** It is the only thing tying the two repositories together.

## Merge order

The two pull requests are **ordered, not simultaneous**:

1. Open the catalog pull request.
2. Open the application pull request — regenerated client, `SOURCE.md`, and call sites — cross-linked to the catalog one.
3. **Merge the catalog pull request to its default branch first.**
4. Rebase the application pull request, regenerate against the catalog's default branch, then merge.

The reason is mechanical: the committed client must be reproducible by regenerating off the catalog's default branch. Merging the application side first makes a regeneration off that branch produce a *different* client, one missing the new event. The committed client drifts, and its recorded provenance becomes false.

Keep the catalog pull request rebased on its default branch while it is open. If other events land meanwhile, regenerating from the stale branch silently drops them.

## Automate the check

Everything above is a convention, and conventions hold exactly as long as every author and reviewer remembers them. One CI job removes that dependency.

Add a `--check` mode to your sync script that regenerates into a temporary directory and diffs against the committed client:

```bash
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
RUDDERSTACK_CLI_EXPERIMENTAL=true RUDDERSTACK_X_LOCAL_TYPER=true \
  rudder-cli typer generate --local --location "$CATALOG_PATH" \
    --tracking-plan-id storefront --platform typescript \
    --output "$tmp" --option outputFileName=index.ts

diff -u src/analytics/generated/index.ts "$tmp/index.ts" \
  || { echo "run the sync script and commit the result" >&2; exit 1; }
```

Then run it on every pull request, against a checkout of the **catalog's default branch**:

```yaml
name: Typed client drift

on:
  pull_request:
    paths: ['src/analytics/generated/**', 'scripts/tp-sync.sh']

jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/checkout@v4
        with:
          repository: your-org/your-catalog-repo
          path: .catalog
          token: ${{ secrets.CATALOG_READ_TOKEN }}

      - name: Install rudder-cli
        env:
          RUDDER_CLI_VERSION: 0.24.0
        run: |
          curl -fsSL "https://github.com/rudderlabs/rudder-iac/releases/download/v${RUDDER_CLI_VERSION}/rudder-cli_Linux_x86_64.tar.gz" \
            | tar -xz -C /usr/local/bin rudder-cli

      - uses: actions/setup-node@v4
        with:
          node-version-file: '.nvmrc'
          cache: 'npm'
      - run: npm ci

      - env:
          CATALOG_PATH: .catalog
        run: npm run tp:check
```

This job catches a call-site pull request merged without regenerating, a client generated from a wrong-branch or dirty checkout, and a catalog change merged without its consumer being regenerated. It also enforces the merge order for free. Because it asks whether the committed client is reproducible from the catalog's default branch, an application pull request that lands ahead of its catalog change cannot pass.

{{< warning >}}
**Pin the Rudder CLI version in this job.** The generated file's header embeds the CLI version that produced it, so an unpinned CLI turns every release into a spurious diff. Bump the pin in the same pull request that commits a client regenerated with the newer CLI.
{{< /warning >}}

Two other details keep the job honest. Check out the catalog's default branch rather than a ref derived from the pull request, and do not run `rudder-cli apply` from it. Applying mutates a workspace, and a pull request check must not. Applying belongs in a deploy job on the catalog repository, after merge.

## Generating at build time instead

You can generate the client during the build rather than committing it. RudderStack does not recommend it:

- It puts the CLI and a catalog checkout on the critical path of every build.
- It hides catalog changes from code review. The diff in the generated client is the most reviewable artifact in the whole workflow — it shows exactly which event shapes changed, in the same pull request as the call sites that use them.
- It makes "which specs produced this client?" unanswerable after the fact.

Commit the client, and let CI prove it is current.

## Review checklist

For the application pull request:

- Does the diff include the regenerated client? If not, the generator was not run.
- Does `SOURCE.md` point at the paired catalog change, on a clean tree?
- Are there any casts, `?? ''`, or sentinel values around a typed call? Each one is a catalog bug in disguise.
- Is the client constructed in exactly one place, with a resolver?
- Are `track` calls inside error-handling paths wrapped so they cannot fail the action?
- Is the catalog pull request linked, and does it merge first?

## Related documentation

- [Generate Bindings from Local Specs]({{< ref "dev-tools/rudder-cli/ruddertyper-v2-local-generation.md" >}}): Generating without a workspace
- [RudderTyper v2 Command Reference]({{< ref "dev-tools/rudder-cli/ruddertyper-v2-command-reference.md" >}}): Complete flag and option list
- [RudderTyper v2 Walkthrough Guide]({{< ref "dev-tools/rudder-cli/ruddertyper-v2-walkthrough.md" >}}): Generating and installing bindings for the first time
- [Tracking Plans and Data Catalog]({{< ref "dev-tools/rudder-cli/data-catalog-and-tracking-plans/" >}}): The YAML schema the catalog uses

