Back to blog
April 7, 2026·8 min·Nima Nejat

Axint's intermediate representation: how we stay language-agnostic

The IR that sits between TypeScript/Python and Swift. Why it's Swift-shaped, not generic, and how it enables multi-language support.

compiler-designirarchitecturedeep-dive

This is the most technically interesting part of Axint, and the part I get asked about most.

We accept two source languages (TypeScript and Python) and emit one target (Swift). The naive approach is direct AST-to-code generation for each pair. That gives you two codegen paths to maintain, and adding a third source language means writing a third generator. Bad.

The better approach: an intermediate representation. Parse source → IR → emit Swift. Each source language only needs a parser-to-IR transform. The IR-to-Swift generator is shared.

The key design decision

Our IR is **not** generic. It's Swift-shaped. It only represents things that Swift App Intents support. There's no abstraction tax.

```typescript type IntentDefinition = { kind: "intent"; name: string; title: string; description: string; parameters: ParameterDefinition[]; requestsDataRetrieval?: boolean; openAppWhenRun?: boolean; };

type SwiftType = | "Int" | "Double" | "String" | "Bool" | "Date" | { array: SwiftType } | { optional: SwiftType } | { custom: string } | { enum: string[] }; ```

If you pass number in TypeScript, it normalizes to Double. If you pass int in Python, same thing. The IR doesn't try to preserve the source language's type system — it only cares about what Swift needs.

This was a deliberate tradeoff. A "universal IR" would let us target Kotlin or Dart someday, but it would also mean every concept has to be representable in every target language. That's a compiler research project, not a product. We chose pragmatism.

Type normalization

The trickiest part is mapping types across languages:

typescript function normalizeType(input: unknown): SwiftType { if (input === String || input === "string") return "String"; if (input === Number || input === "number") return "Double"; if (input === Boolean || input === "boolean") return "Bool"; if (Array.isArray(input)) return { array: normalizeType(input[0]) }; if (typeof input === "string") return { custom: input }; throw new ValidationError(Unknown type: ${input}); }

TypeScript's number → Swift's Double. Python's int → Swift's Int (we special-case this). If you need a specific Swift type, you spell it out. The IR doesn't guess.

Validation at IR time

Before the IR becomes Swift, it goes through validation. This is where we catch problems that the source language can't:

  • Parameters must be codable types
  • Intent names must match Swift naming conventions
  • Circular dependencies in parameters are forbidden
  • Return types must conform to IntentResult

Errors report in terms of your source language and source location — not in terms of the IR or the generated Swift. When a Python developer sees "parameter 'user' references undefined type UserModel," they know exactly what to fix. Details on the [validator architecture](/blog/validator-architecture).

IR as the registry format

The [Axint registry](/blog/registry-architecture) publishes and consumes IR, not source code. When you publish an intent package, you publish the IR. When you import one, you get IR back. This keeps the registry format stable even as the TypeScript and Python SDKs evolve independently.

It also means you can author a package in TypeScript and someone can consume it from Python (or vice versa). The IR is the contract.

What I'd do differently

If I were starting over, I'd make the IR serializable from day one. We added JSON serialization later and had to retrofit it. I'd also add source-map-style location tracking to every IR node — right now we reconstruct locations during validation, which is fragile.

But the core decision — Swift-shaped, not generic — was right. We ship fast because we don't abstract things we don't need to abstract.