ReferenceAvo CodegenLibrary codegen

Library codegen for Kotlin, Swift, and TypeScript

Avo can emit generated code in two shapes for Kotlin, Swift, and TypeScript sources: the default single-source output (one file per source) and the library mode output that splits the runtime from the per-source types. Both modes are supported — they serve different use cases and you can generate either from the same source.

Library mode is the better fit when you have multiple Avo sources sharing the same app, when you’re building an internal tracking library reused across apps, or when you want the runtime code separated from the per-source event types. If that library is shared between repositories, see section 4 for how to stop each repo regenerating it. If another module uses the Swift or Kotlin app file, see section 5.


1. The shape of library mode

avo pull emits multiple files per source instead of one. The shared source-independent runtime — Avo class, validation helpers, AvoEvent protocol/interface, AvoInvoke — lives in a separate “library” file. The per-source “app” file contains only the things that change when your tracking plan changes: per-event classes and the codegen-bound IDs. Optionally, a third file is extracted with everything needed for Avo initialization, so initialization can be decoupled from the apps too.

What gets generated

  • Library file: the runtime. AvoEnv, AvoAssert, AvoInvoke, the AvoEvent protocol/interface, and the Avo class. Regenerated rarely. Every source generates an identical library file. Shared across all sources/apps. Optionally orchestrated from a single place — “the library”.
  • App file: per-source types and a thin config object. Regenerated whenever the tracking plan source changes.
  • In TypeScript, a third file AvoConfig.ts holds the source config with schemaId/actionId/branchId/sourceId plus destination API keys. It has no runtime imports — just the values.

Mental model

Events are values you construct, not method calls. You build an instance of LoginSuccessEvent(timestamp: ..., teamId: ..., ...) and pass it to avo.process(event) (Swift/Kotlin) or avo.track(event) (TypeScript). The call returns a map keyed by destination name, with the per-destination payload (event name + properties) for each one.

The boundary: the library file knows nothing about your tracking plan. The app file knows nothing about how validation or invocation metrics work. You decide what to do with the per-destination map the call returns — either fan out manually, or hand off to destination implementations the runtime calls for you.


2. Quick start

Enable library mode on a per-source basis with the --forceFeatures flag on avo pull. The flag names match the language:

avo pull --forceFeatures SwiftLibraryInterface
avo pull --forceFeatures KotlinLibraryInterface
avo pull --forceFeatures TypeScriptLibraryInterface

You can also enable these features in your Avo workspace if you’d like them on by default — reach out and we’ll switch them on for your source.

Kotlin

avo pull produces:

  • Analytics.kt — per-event data classes and AvoTrackingPlanConfig
  • AnalyticsLibraryInterface.kt — runtime (Avo class, AvoEvent, AvoAssert, AvoInvoke)

Both files share the same package (inferred from the source path, default sh.avo). Treat them like any other Kotlin files in your project — drop them anywhere in your source tree that fits your module layout.

Initialize using the initAvo extension that’s generated alongside the per-event classes:

import sh.avo.Avo
import sh.avo.AvoEnv
import sh.avo.AvoVerificationError
import sh.avo.LoginSuccessEvent
import sh.avo.initAvo
 
val avo = Avo.initAvo(env = AvoEnv.DEV)
 
try {
    val payloads = avo.process(LoginSuccessEvent(
        timestamp = 1730000000,
        teamId = "team_42",
        teamDomain = "acme.example"
    ))
 
    payloads["custom"]?.let { event ->
        myAnalyticsSdk.logEvent(event.name, event.properties)
    }
} catch (e: AvoVerificationError) {
    // Validation failed and strict mode is on.
    println("[avo] ${e.messages.joinToString(", ")}")
}

process() returns Map<String, AvoDestinationEvent> keyed by destination name (lowercased identifier from your Avo workspace). In development and staging it throws AvoVerificationError when validation fails and strict = true (the default), otherwise it logs a warning and continues. In production, runtime validation is skipped entirely — process() never throws or logs, and always returns the destination map. Returns an empty map in noop mode.

Swift

avo pull produces:

  • Analytics.swift — AvoTypes namespace, per-event structs, AvoTrackingPlanConfig, and an extension Avo with a no-config initAvo
  • AnalyticsLibraryInterface.swift — runtime

The app file imports Library. You decide where the library file lives: package it as a Swift module called Library (SPM package, Xcode target, internal framework — whichever fits your project) so the import resolves, or drop both files into the same module and remove the import Library line from the app file by hand. Either approach works; the library file is just Swift source.

Initialize:

import Library
 
let avo = Avo.initAvo(env: .dev)
 
do {
    let payloads = try avo.process(event: LoginSuccessEvent(
        timestamp: 1730000000,
        teamId: "team_42",
        teamDomain: "acme.example"
    ))
 
    if let event = payloads["custom"] {
        MyAnalyticsSDK.logEvent(name: event.name, properties: event.properties)
    }
} catch let error as AvoVerificationError {
    // Validation failed and strict mode is on.
    print("Avo verification: \(error.messages.joined(separator: \", \"))")
}

process(event:) returns [String: AvoDestinationEvent]. In development and staging it throws AvoVerificationError when validation fails and strict is on, otherwise it logs via NSLog and continues. In production, runtime validation is skipped entirely — process(event:) never throws or logs. Returns an empty dictionary in noop mode.

TypeScript

TypeScript library mode has two file layouts. By default avo pull emits one file for all events; with the “Generate one file per event” source setting on (Source → Avo Codegen Setup), each event gets its own module. Both layouts expose the same ./Avo import surface, so the init, destination, and tracking code below applies to either one.

One file for all events

avo pull produces three files:

  • Avo.ts — per-event classes (LoginSuccessEvent, etc.) extending BaseAvoEvent, plus re-exports of the public runtime surface
  • AvoLibrary.ts — runtime (Avo class, AvoEvent, AvoAssert, BaseAvoEvent)
  • AvoConfig.ts — codegen-bound config (default export), DestinationKey union, and per-destination API keys

AvoConfig.ts is the only file that contains your schemaId/actionId/branchId. Move it (and update the import path) wherever you want Avo.init to live — at the app level, a library level, or any module boundary.

There are no runtime dependencies outside the standard library and fetch (used by AvoInvoke).

Avo.init takes two arguments: a runtime AvoConfig and the codegen-bound config (default-exported from AvoConfig.ts). The recommended starting point is to wire all destinations through the runtime:

import { Avo, AvoEnv, AvoVerificationError, LoginSuccessEvent } from './Avo';
import codegenConfig from './AvoConfig';
 
const avo = Avo.init(
  {
    env: AvoEnv.Dev,
    destinations: {
      Custom: {
        make(env, apiKey) { /* initialize your SDK */ },
        logEvent(event) { myAnalyticsSDK.logEvent(event.name, event.properties); },
        setUserProperties(userId, props) { myAnalyticsSDK.identify(userId, props); },
      },
    },
  },
  codegenConfig,
);
 
try {
  avo.track(new LoginSuccessEvent(1730000000, 'team_42', 'acme.example'));
} catch (e) {
  if (e instanceof AvoVerificationError) {
    console.error('[avo]', e.messages.join(', '));
  } else {
    throw e;
  }
}

AvoCustomDestination has the same shape as the custom destination interface in the single-source output — make (optional), logEvent, setUserProperties, identify, unidentify, logPage, and revenue — so an existing single-source destination implementation is drop-in compatible. destinations is all-or-nothing: if you provide any, you must provide an implementation for every DestinationKey or Avo.init will throw.

If the all-or-nothing model doesn’t fit (for example, you want to fan out manually for some destinations), omit destinations and fan out yourself:

const avo = Avo.init({ env: AvoEnv.Dev }, codegenConfig);
 
try {
  const payloads = avo.track(new LoginSuccessEvent(1730000000, 'team_42', 'acme.example'));
 
  const custom = payloads['Custom'];
  if (custom) {
    myAnalyticsSDK.logEvent(custom.name, custom.properties);
  }
} catch (e) {
  if (e instanceof AvoVerificationError) {
    console.error('[avo]', e.messages.join(', '));
  } else {
    throw e;
  }
}

track() returns Record<string, AvoDestinationEvent> keyed by DestinationKey from AvoConfig.ts. It throws AvoVerificationError the same way Kotlin/Swift do.

One file per event

With the “Generate one file per event” source setting on, each event is split into its own module for better tree‑shaking. avo pull then emits an AvoEvents/ directory and one extra file:

One file for all events
├─ Avo.ts          # per-event classes + runtime re-exports
├─ AvoLibrary.ts   # runtime
└─ AvoConfig.ts    # codegen-bound config

One file per event
├─ Avo.ts                    # thin barrel — re-exports everything below
├─ AvoLibrary.ts             # runtime (unchanged)
├─ AvoConfig.ts              # codegen-bound config (unchanged)
├─ AvoAppSystemProperties.ts # AppSystemProperties singleton
└─ AvoEvents/
   ├─ index.ts
   ├─ types.ts               # shared object and enum types
   └─ <Event>.ts             # one module per event

The public import surface does not change. Avo.ts becomes a thin barrel that re‑exports the runtime (Avo, AvoEnv, AvoVerificationError, …), every per‑event class, AppSystemProperties, and the shared types — so import { Avo, AvoEnv, AvoVerificationError, LoginSuccessEvent } from './Avo' keeps working exactly as in the one-file layout above. You don’t need to change any call sites.

AppSystemProperties moves out of Avo.ts into its own AvoAppSystemProperties.ts file so the per‑event modules can import it from a sibling — importing it from the Avo.ts barrel would create an import cycle, since the barrel re‑exports the per‑event files. You still import AppSystemProperties from ./Avo; this is only an internal file split.


3. Differences from the single-source output

If you’re switching an existing source from the single-source output to library mode, here’s what changes — and what stays the same.

Before / after

Kotlin

// Single-source
val avo = Avo(env = AvoEnv.DEV, customDestination = MyCustomDestination())
avo.loginSuccess(
    timestamp = 1730000000,
    teamId = "team_42",
    teamDomain = "acme.example",
)
 
// Library mode
val avo = Avo.initAvo(env = AvoEnv.DEV)
try {
    val payloads = avo.process(LoginSuccessEvent(
        timestamp = 1730000000,
        teamId = "team_42",
        teamDomain = "acme.example",
    ))
    payloads["custom"]?.let { event ->
        myAnalyticsSdk.logEvent(event.name, event.properties)
    }
} catch (e: AvoVerificationError) {
    println("[avo] ${e.messages.joinToString(", ")}")
}

Swift

// Single-source
let avo = Avo(env: .dev, customDestination: MyCustomDestination())
avo.loginSuccess(
    timestamp: 1730000000,
    teamId: "team_42",
    teamDomain: "acme.example"
)
 
// Library mode
let avo = Avo.initAvo(env: .dev)
do {
    let payloads = try avo.process(event: LoginSuccessEvent(
        timestamp: 1730000000,
        teamId: "team_42",
        teamDomain: "acme.example"
    ))
    if let event = payloads["custom"] {
        MyAnalyticsSDK.logEvent(name: event.name, properties: event.properties)
    }
} catch let error as AvoVerificationError {
    print("Avo verification: \(error.messages.joined(separator: \", \"))")
}

TypeScript

// Single-source
import Avo from './Avo';
Avo.initAvo({ env: Avo.AvoEnv.Dev }, /* destinations */ {});
Avo.loginSuccess({
  timestamp: 1730000000,
  teamId: 'team_42',
  teamDomain: 'acme.example',
});
 
// Library mode
import { Avo, AvoEnv, AvoVerificationError, LoginSuccessEvent } from './Avo';
import codegenConfig from './AvoConfig';
 
const avo = Avo.init({ env: AvoEnv.Dev }, codegenConfig);
 
try {
  const payloads = avo.track(new LoginSuccessEvent(1730000000, 'team_42', 'acme.example'));
 
  const custom = payloads['Custom'];
  if (custom) {
    myAnalyticsSDK.logEvent(custom.name, custom.properties);
  }
} catch (e) {
  if (e instanceof AvoVerificationError) {
    console.error('[avo]', e.messages.join(', '));
  } else {
    throw e;
  }
}

What stays the same

  • Event names, property names, and validation rules. The tracking plan is unchanged — only the shape of the generated code differs.
  • Strict / noop modes. strict (throw on validation failure in development and staging) and noop (drop network calls) behave the same as in the single-source output — runtime validation is skipped entirely in production, and noop is force-disabled in production.
  • Inspector integration. Construct your Avo Inspector instance from the SDK and pass it into the init call — same as the single-source output.
  • Implementation status tracking. The CLI reports event-usage status the same way regardless of mode.
  • Destination interface. TypeScript’s AvoCustomDestination has the same shape as the single-source custom destination (make, logEvent, setUserProperties, identify, unidentify, logPage, revenue) — an existing single-source implementation is drop-in compatible.
  • The avo pull workflow. Same command, same authentication, same source/destination configuration in your Avo workspace.
  • Destination API keys. Still embedded by codegen. In TypeScript they’re in AvoConfig.ts; in Swift/Kotlin they’re inside AvoTrackingPlanConfig and used by the initAvo extension.

What changes

  1. Avo constructor signature. The single-source Avo(env:, customDestination:, ...) (Swift/Kotlin) and the free-function Avo.initAvo(config, destinations) (TypeScript) become Avo.initAvo(env:) (Swift/Kotlin) and Avo.init(config, codegenConfig) (TypeScript).
  2. Event method calls become event constructors and a single process / track call. Each avo.someEvent(...) becomes avo.process(SomeEvent(...)) (Swift/Kotlin) or avo.track(new SomeEvent(...)) (TypeScript).
  3. process() / track() throws AvoVerificationError. In development and staging, library mode in all three languages throws a typed AvoVerificationError when strict is on — wrap calls in try / do-catch, see the quick-start examples. In production, runtime validation is skipped entirely, so the call never throws.
  4. TypeScript: how custom destinations are wired. Custom destinations are passed in the destinations map of the runtime config, keyed by DestinationKey, instead of as separate Avo constructor arguments. All-or-nothing — provide an implementation for every DestinationKey, or omit destinations entirely and fan out manually. The AvoCustomDestination interface itself has the same shape as the single-source custom destination.
  5. System properties. setSystemProperties(...) / Avo.setSystemProperties(...) becomes AppSystemProperties.configure(...) (Kotlin) or AppSystemProperties.shared.configure({...}) (Swift, TypeScript). verify() and process() / track() throw AvoVerificationError with the message “AppSystemProperties.configure() must be called before sending events.” if a tracked event needs system properties and configure() hasn’t run.
  6. Imports. Add the library file to your build and import its types where you previously imported from the single file. In Swift this is import Library (if you packaged it that way); in Kotlin it’s import sh.avo.* (or whatever package your source path resolves to); in TypeScript it’s import { ... } from './Avo' (the app file re-exports the public library surface).
  7. Swift and Kotlin: the app file is internal. The event types, AvoTypes, AppSystemProperties, initAvo and, in Kotlin, AvoTrackingPlanConfig are visible only inside the module the app file is compiled into. To use them from another module, see section 5.

4. Sharing one library interface across repositories

When one repository owns the library file and others only consume it, a plain avo pull on either side generates files that do not belong there. The libraryInterface.fileFilter setting in avo.json tells avo pull which side to generate. It only affects sources in library mode.

ValueGeneratesUse in
all (default)Every file, as beforeRepos holding both the interface and the events
interface-onlyOnly the library file: AnalyticsLibraryInterface.kt / .swift, AvoLibrary.tsThe repo that owns the shared interface
events-onlyEverything else: the app file, and in TypeScript also AvoConfig.ts, AvoEvents/ and AvoAppSystemProperties.tsClient repos importing the interface from a shared module

AvoConfig.ts is on the events side despite its name: it holds the source-specific ids and keys a shared interface must not contain.

Configuring it

Add a libraryInterface object to avo.json by hand — avo init does not prompt for it. At the top level it applies to every source; on a sources entry it overrides the top level for that source. avo pull --libraryInterfaceFileFilter <value> overrides both for a single run without writing to avo.json. Each source resolves: flag → source → top level → all.

Client repos also need libraryInterface.sharedModule — the module the generated app files import the interface from, placed top-level or per-source the same way:

LanguageValue
KotlinPackage name of the shared interface, e.g. "sh.avo.shared"
TypeScriptModule specifier. A bare specifier ("@acme/analytics") is emitted verbatim in every generated import of the library interface. A relative one ("../shared/AvoLibrary") is resolved relative to the output folder, so it only works within one repository
SwiftNot used — the app file already does import Library; ship the shared interface as that module

The value is passed to codegen verbatim and only has an effect when the resolved filter is not all. The repo that owns the interface does not need it.

Shared repo:

{
  "libraryInterface": { "fileFilter": "interface-only" },
  "sources": [{ "id": "...", "name": "Web", "path": "src/Avo.ts", "actionId": "...", "branchId": "...", "updatedAt": "..." }]
}

Each client repo:

{
  "libraryInterface": { "fileFilter": "events-only", "sharedModule": "@acme/analytics" },
  "sources": [{ "id": "...", "name": "Web", "path": "src/Avo.ts", "actionId": "...", "branchId": "...", "updatedAt": "..." }]
}

In a monorepo with a single avo.json, put the same libraryInterface object on the individual sources entries instead. path stays as usual: an interface-only source with path: "src/Avo.ts" writes only src/AvoLibrary.ts.

Good to know

  • Stale files are reported, never deleted. After a filter change, avo pull warns [avo] Warning: <file> is no longer generated here because of the current libraryInterfaceFileFilter… for each file it used to write, on every pull until you remove it.
  • avo.json conflicts: if the two sides disagree on the file filter, your current branch’s value is kept and the discarded one is printed.
  • Invalid values are rejected by every command that reads avo.json, and so are unknown keys inside libraryInterface.
  • The flat keys from Avo CLI 3.6.0 still work. Avo CLI 3.6.0 reads these settings as top-level or per-source libraryInterfaceFileFilter and libraryInterfaceSharedModule, and newer versions keep reading them. You can mix the two forms across levels (flat at the top level, libraryInterface on a source), but not at the same level: Set either libraryInterface or libraryInterfaceFileFilter on source '<name>' in avo.json, not both. The libraryInterface object needs Avo CLI 3.7.0+.
  • Which sources can share one interface: the library file has no source-specific ids or keys, but its AvoEnv cases come from the source’s destination environments — share it between repos pulling the same source, or sources with an identical set of destination environments.

5. Using the app file from another module

In library mode, the Swift and Kotlin app file is generated internal. That covers the event types, the AvoTypes namespace, AppSystemProperties, the initAvo extension and AvoTrackingPlanConfig (already internal in Swift; it can’t be made public). internal means they’re visible only inside the module the file is compiled into: a Swift module or target, or a Kotlin Gradle module. The library file isn’t affected (Avo, AvoEvent, AvoEnv and the rest of the runtime stay public), and neither is TypeScript output.

  • The app file and the code that tracks events are in the same module: nothing changes.
  • A host app imports two modules that each contain an app file: their declarations no longer collide. Before, the host got 'AvoTypes' is ambiguous for type lookup in Swift and, with star imports on Kotlin 2, Overload resolution ambiguity. In Kotlin, give each app file its own package; the package comes from the source path, and is sh.avo when the path isn’t under src/main/kotlin or src/main/java. Kotlin 1.x hosts also need explicit imports, see Good to know.
  • Code in another module uses the app file, for example a host app that constructs events from a feature module’s app file: that code stops compiling after the next avo pull. List what it needs in libraryInterface.publicItems.

Keeping chosen items public

Add libraryInterface.publicItems to the source’s entry in avo.json (Avo CLI 3.7.0+). It takes up to 1,000 names:

{
  "sources": [
    {
      "id": "...",
      "name": "iOS",
      "path": "Sources/Analytics/Avo.swift",
      "actionId": "...",
      "branchId": "...",
      "updatedAt": "...",
      "libraryInterface": {
        "publicItems": ["LoginSuccessEvent", "AvoTypes.Platform", "AppSystemProperties", "initAvo"]
      }
    }
  ]
}

Each entry is a name exactly as it appears in the generated app file:

EntryMakes public
An event type, e.g. LoginSuccessEventThat event, so another module can construct it and pass it to process
AvoTypes.<Name>, e.g. AvoTypes.PlatformOne type directly inside AvoTypes: an allowed-values enum or an object. Property groups are listed together, as AvoTypes.group (Swift) or AvoTypes.Group (Kotlin), which makes all of them public
AppSystemPropertiesThe system properties, so another module can call configure(...)
initAvoThe initAvo extension, so another module can initialize Avo

You don’t need to list the types a listed item uses. A public declaration can’t use an internal type, so codegen also makes public every AvoTypes type in a listed item’s signature, including types nested inside objects. For example, listing LoginSuccessEvent also makes public the enums and objects its properties use, and, if it has a property group, all property groups. Once anything inside AvoTypes is public, the namespace is public too, and the types directly inside it that nothing needs are generated internal.

If the source has system properties, initAvo takes them as parameters and configures AppSystemProperties itself, so listing initAvo is enough to initialize Avo from another module. List AppSystemProperties only if another module also calls configure(...) directly. A source without system properties has no AppSystemProperties, so listing it prints the warning below.

Good to know

  • Names that match nothing don’t fail the pull. Each one prints [avo] Warning: libraryInterface.publicItems entry '<name>' for source "<source>" does not match any declaration in the generated app file and was ignored. A type name without the AvoTypes. prefix, such as Platform, is one of these. A source whose file filter is interface-only generates no app file, so it doesn’t warn.
  • Invalid values are rejected by every command that reads avo.json. The list must hold at most 1,000 strings, each non-empty and without leading or trailing spaces, and the error names the source and the bad entry, for example Invalid libraryInterface.publicItems on source '<name>' in avo.json — lists 1001 entries; at most 1000 are allowed.
  • Per source only. At the top level, publicItems is rejected: Unknown key 'publicItems' in libraryInterface in avo.json — allowed keys are fileFilter, sharedModule. avo pull has no flag for it.
  • There is no flat form. Unlike the settings in section 4, a flat libraryInterfacePublicItems key is rejected: libraryInterfacePublicItems on source '<name>' in avo.json is not supported — set libraryInterface.publicItems instead.
  • Older CLIs don’t send the list, so everything in the app file stays internal.
  • Swift and Kotlin library mode only. In single-source output and in TypeScript the list has no effect and produces no warning.
  • Kotlin 1.x hosts. Say a host file star-imports two library packages (import com.acme.checkout.*, import com.acme.search.*) and uses a name that is public in one and internal in the other. The Kotlin 1.x compiler reports Unresolved reference. Kotlin 2.0+ resolves the name to the public declaration, and explicit imports (import com.acme.search.AvoTypes) work with either compiler.

Avo is the analytics governance platform for product data that lets you define it consistently, implement it correctly and trust it everywhere. Using an AI agent? Connect it to the Avo MCP at https://mcp.avo.app/mcp and it can read and change your tracking plan on a branch.