# ajsc — Another JSON Schema Converter

**ajsc** transforms JSON Schema documents into idiomatic, language-native code for **TypeScript**, **Kotlin**, and **Swift**. It exposes a small function-style API for typical codegen pipelines, plus class-based converters for advanced extension.

```bash
npm install ajsc
```

```ts
import { emitTypescript, emitKotlin, emitSwift } from "ajsc";

const schema = {
  title: "User",
  type: "object",
  properties: {
    id:    { type: "integer" },
    name:  { type: "string" },
    email: { type: "string", format: "email" },
  },
  required: ["id", "name"],
};

const ts = emitTypescript(schema);
const kt = emitKotlin(schema);
const sw = emitSwift(schema);
```

Each emit function returns the same shape:

```ts
interface EmitResult {
  code: string;                        // declarations only — no `import` lines
  rootTypeName: string;                // top-level emitted type ("User")
  extractedTypeNames: string[];        // additional types emitted (nested objects, enums, variants)
  imports: string[];                   // language-native module/symbol paths to import
  referencedNamedTypes: string[];      // external types referenced via x-named-type (not declared by ajsc)
}
```

`code` contains type declarations only. Consumers assemble the final source file by combining `imports` with `code`.

`referencedNamedTypes` lists the external type names this output refers to via the `x-named-type` keyword. ajsc does **not** declare these — the caller owns those definitions and is responsible for making them available (e.g., via an import or a sibling emit call). The list is deduped and sorted lexicographically. It is empty when no `x-named-type` annotations are present.

---

## Output examples

### TypeScript

```ts
import { emitTypescript } from "ajsc";

const { code } = emitTypescript({
  title: "User", type: "object",
  properties: { id: { type: "integer" }, name: { type: "string" } },
  required: ["id", "name"],
});

// code:
// export type User = { id: number; name: string; };
```

### Kotlin (default: kotlinx-serialization)

```ts
import { emitKotlin } from "ajsc";

const { code, imports } = emitKotlin({
  title: "User", type: "object",
  properties: { id: { type: "integer" }, name: { type: "string" } },
  required: ["id", "name"],
});

// imports: ["kotlinx.serialization.Serializable"]
// code:
// @Serializable
// data class User(
//   val id: Long,
//   val name: String,
// )
```

Pass `serializer: "none"` for plain types with no annotations.

### Swift (default: Codable)

```ts
import { emitSwift } from "ajsc";

const { code, imports } = emitSwift({
  title: "User", type: "object",
  properties: { id: { type: "integer" }, name: { type: "string" } },
  required: ["id", "name"],
});

// imports: []
// code:
// public struct User: Codable {
//   public let id: Int64
//   public let name: String
// }
```

Pass `serializer: "none"` to drop `Codable` conformance.

### Assembling a final file

```ts
const { code, imports } = emitKotlin(schema);
const file = [...imports.map((i) => `import ${i}`), "", code].join("\n");
```

Swift `imports` are module names (`["Foundation"]`); Kotlin `imports` are fully-qualified symbol paths. TypeScript `imports` is always empty — for TypeScript consumers that need to know which external named types are referenced (via `x-named-type`), use `referencedNamedTypes` instead.

---

## Package layout

| Subpath          | Exports                                                                                                |
|------------------|--------------------------------------------------------------------------------------------------------|
| `ajsc`           | `emitTypescript`, `emitKotlin`, `emitSwift`, `EmitResult`, plus the converter classes (re-exported)    |
| `ajsc/typescript`| `TypescriptConverter`, `TypescriptBaseConverter`, related types                                        |
| `ajsc/kotlin`    | `KotlinConverter`, `KotlinBaseConverter`, `sanitizeKotlinIdentifier`, `KOTLIN_RESERVED`                |
| `ajsc/swift`     | `SwiftConverter`, `SwiftBaseConverter`, `sanitizeSwiftIdentifier`, `SWIFT_RESERVED`                    |
| `ajsc/converter` | `BaseConverter`, `Emitter`, `walkIR`, `LanguageProfile`, `BaseConverterOpts`, `RefTypeEntry`, etc.     |
| `ajsc/ir`        | `JSONSchemaConverter` (JSON Schema → IRNode tree)                                                      |
| `ajsc/types`     | `IRNode`, `ILanguageConverter`, signature types                                                        |
| `ajsc/utils`     | `PathUtils`, `toPascalCase`                                                                            |

The root (`ajsc`) is the typical entry point. Subpaths are for advanced use — extending a converter, accessing the IR layer, building a custom language target.

---

## Function-style vs class-style

```ts
// Function-style — recommended for most consumers.
import { emitKotlin } from "ajsc";
const result = emitKotlin(schema, opts);

// Class-style — for subclassing or accessing converter internals.
import { KotlinConverter } from "ajsc/kotlin";
const converter = new KotlinConverter(schema, opts);
console.log(converter.code, converter.imports);
```

Both produce identical output; the function form just bundles `code`, `rootTypeName`, `extractedTypeNames`, `imports` into a single return value. For codegen pipelines, the function form is usually nicer — it avoids the `new` and gives a stable result shape.

---

## Options

All three converters accept `BaseConverterOpts` plus language-specific options.

### Shared options (`BaseConverterOpts`)

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `rootTypeName` | `string` | `schema.title \|\| "Root"` | Override the top-level type name. |
| `arrayItemNaming` | `string \| false` | `false` | Postfix for array-item type names. `false` = property name, `"Item"` = `ContactsItem`. |
| `depluralize` | `boolean` | `true` | Singularize array-item path segments (`entries` → `Entry`). |
| `uncountableWords` | `string[]` | `[]` | Words to skip when singularizing. Built-ins: `data`, `metadata`. |
| `unsupportedUnions` | `"throw" \| "fallback"` | `"throw"` | What to do with untagged unions: throw with a path-bearing error, or emit a language fallback type (`JsonElement` / `AnyCodable` / `any`). |

### TypeScript (`TypescriptConverterOpts`)

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `inlineTypes` | `boolean` | `false` | If true, nested object types are inlined rather than extracted to named aliases. |
| `enumStyle` | `"union" \| "enum"` | `"union"` | `"union"` emits `"a" \| "b" \| "c"`; `"enum"` emits `export enum Status { ... }`. |
| `jsdoc` | `boolean` | `false` | Emit JSDoc comments from JSON Schema `description` and `title`. Requires `inlineTypes: false`. |

### Kotlin (`KotlinConverterOpts`)

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `serializer` | `"kotlinx" \| "none"` | `"kotlinx"` | Emit `@Serializable`/`@SerialName`/`@Contextual` annotations and matching imports. `"none"` emits plain types. |
| `packageName` | `string` | `undefined` | If set, emit `package <name>` at the top of the output. |
| `inlineTypes` | `boolean` | `false` | If true, nested object types emit as nested `data class` declarations inside their parent's body block instead of extracting to top-level siblings. Top-level enums and discriminated unions remain extracted. See "Emitting multiple schemas into one namespace" for the motivation. |

### Swift (`SwiftConverterOpts`)

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `serializer` | `"codable" \| "none"` | `"codable"` | Emit `: Codable` conformance, `CodingKeys` enums, and discriminated-union `init(from:)`/`encode(to:)` plumbing. `"none"` emits plain types. |
| `accessLevel` | `"public" \| "internal"` | `"public"` | Access modifier on emitted types and members. |
| `inlineTypes` | `boolean` | `false` | If true, nested object types emit as nested `struct` declarations inside their parent's body block instead of extracting to top-level siblings. Top-level enums and discriminated `enum`s remain extracted. See "Emitting multiple schemas into one namespace" for the motivation. |

---

## Type mapping

### TypeScript

JSON Schema → idiomatic TS. `string` → `string`, `integer` → `number`, etc. Discriminated `oneOf`/`anyOf` → tagged union types. `$ref: "#"` → direct self-reference.

### Kotlin (target: JVM)

| JSON Schema | Kotlin | Notes |
|-------------|--------|-------|
| `string` | `String` | |
| `string, format: date-time` | `java.time.Instant` | `@Contextual` under kotlinx |
| `string, format: date` / `time` | `java.time.LocalDate` / `LocalTime` | `@Contextual` under kotlinx |
| `string, format: uuid` | `java.util.UUID` | `@Contextual` under kotlinx |
| `string, format: uri` | `java.net.URI` | `@Contextual` under kotlinx |
| `string, format: email` / `hostname` / `ipv4` / `ipv6` | `String` + KDoc note | |
| `integer` | `Long` | `format: int32` → `Int` |
| `number` | `Double` | |
| `boolean` | `Boolean` | |
| `array` | `List<T>` | |
| `object` | `data class` | `@Serializable` under kotlinx |
| `enum` (string) | `enum class` | `@Serializable` under kotlinx |
| `oneOf` w/ discriminator | `sealed interface` | `@JsonClassDiscriminator` under kotlinx |
| Tuple length 2 / 3 | `Pair<A,B>` / `Triple<A,B,C>` | length > 3 throws |

### Swift

| JSON Schema | Swift | Notes |
|-------------|-------|-------|
| `string` | `String` | |
| `string, format: date-time` | `Foundation.Date` | requires `JSONDecoder.dateDecodingStrategy = .iso8601` |
| `string, format: uuid` | `Foundation.UUID` | |
| `string, format: uri` | `Foundation.URL` | |
| `string, format: email` etc. | `String` + `///` doc note | |
| `integer` | `Int64` | `format: int32` → `Int32` |
| `number` | `Double` | |
| `boolean` | `Bool` | |
| `array` | `[T]` | |
| `object` | `struct` | `: Codable` by default |
| `enum` (string) | `enum: String, Codable` | raw-value enum |
| `oneOf` w/ discriminator | `enum` w/ associated values | hand-rolled `init(from:)`/`encode(to:)` |
| Tuple (homogeneous) | `[T]` | |
| Tuple (heterogeneous, codable) | throws | use `serializer: "none"` for native tuples |

Schema-level `default` values are emitted inline for primitive types in Kotlin/Swift (`val foo: String = "x"`, `let foo: Int64 = 0`).

---

## Emitting multiple schemas into one namespace

Codegen pipelines that emit several sibling schemas into a shared output (e.g., wrapping an endpoint's `pathParams`, `query`, `body`, `response`, and error types under a single Kotlin `object` or Swift `enum`) need to avoid duplicate-name collisions across the emit calls. Each emit call has its own private name-tracking state by default, so two slots with a nested `address: { type: "object" }` would each emit a `data class Address(...)` — a duplicate-class compile error in the merged output.

There are two ways to fix this: nest extracted types inside the parent (recommended for Kotlin/Swift), or share a name registry across calls. Pick whichever output shape your pipeline wants.

### Recommended: `inlineTypes: true` (Kotlin / Swift)

Set `inlineTypes: true` per emit call. Each parent's nested object types are emitted as nested `data class` (Kotlin) or `struct` (Swift) declarations inside the parent's body block, scoped to its namespace. Cross-call collisions disappear structurally — no shared registry plumbing required.

```ts
import { emitKotlin } from "ajsc";

const body     = emitKotlin(bodySchema,     { inlineTypes: true, rootTypeName: "Body" });
const response = emitKotlin(responseSchema, { inlineTypes: true, rootTypeName: "Response" });

// body.code:
//   data class Body(val address: Address, ...) {
//     data class Address(val street: String, ...)
//   }
// response.code:
//   data class Response(val address: Address, ...) {
//     data class Address(val street: String, ...)
//   }
//
// Concatenated under one `object Endpoint { ... }` — no name collisions:
// Body.Address and Response.Address are different scoped names.
```

`inlineTypes: true` only nests plain object types. Top-level enums and discriminated unions (sealed interface in Kotlin, enum w/ associated values in Swift) remain at the same level as the parent — they have their own dedup story (canonical-key for enums; self-contained namespace for discriminated unions) and don't benefit from nesting.

The TypeScript converter has had `inlineTypes` since v6 — it produces anonymous inline type literals (a different language mechanism, same `inlineTypes` option name).

### Alternative: shared `nameRegistry` + per-slot `namePrefix`

Use this when you specifically want flat top-level output (cleaner imports, simpler navigation in IDE outline panels).

Pass a shared `nameRegistry: Set<string>` across calls. The converter uses it as its declaration registry and mutates it as new types are emitted. Pair with `namePrefix` for clean per-slot names:

```ts
import { emitKotlin } from "ajsc";

const registry = new Set<string>();

const body = emitKotlin(bodySchema, {
  nameRegistry: registry,
  namePrefix: "Body",
  rootTypeName: "Body",
});

const response = emitKotlin(responseSchema, {
  nameRegistry: registry,
  namePrefix: "Response",
  rootTypeName: "Response",
});

// body.code:     `data class Body(...)` + `data class BodyAddress(...)`
// response.code: `data class Response(...)` + `data class ResponseAddress(...)`
// Concatenated under one `object Endpoint { ... }` — no name collisions.
```

### Without `namePrefix`

You can pass `nameRegistry` alone — collisions still resolve correctly via the standard fallback path. But the resulting names are awkward:

```ts
const registry = new Set<string>();
emitKotlin(bodySchema,     { nameRegistry: registry, rootTypeName: "Body" });
emitKotlin(responseSchema, { nameRegistry: registry, rootTypeName: "Response" });

// body emits:     `data class Address(...)`         ← bare name (first-come-first-served)
// response emits: `data class AddressType(...)`     ← postfix fallback (semantically wrong)
```

`AddressType` is a literal type name in the emitted Kotlin/Swift — but it's not a "type of address," it's just another `Address`. The fallback exists because path-collision escalation was designed for single-call use; in cross-call scenarios where every slot's path looks the same, the fallback fires immediately. **Recommendation: always pair `nameRegistry` with a per-slot `namePrefix`.** That's the pattern shown above and the one the integration tests assert against.

### Notes

`namePrefix` does **not** affect the root type name (use `rootTypeName` for that). It only applies to extracted nested types, and it's applied unconditionally — every nested type gets the prefix, not just colliding ones. That can produce slightly verbose names for unique types (e.g. `BodyContact` even when no other slot has a `Contact`), but it's predictable and avoids the alternative's two-pass complexity.

Both `nameRegistry` and `namePrefix` work for all three languages. They're most useful for Kotlin and Swift, which require named declarations for non-primitive types. TypeScript supports them too but rarely needs them — most codegen pipelines use `inlineTypes: true` to flatten nested types instead.

### Choosing between `inlineTypes` and `nameRegistry`

| If you want…                                     | Use                                          |
|--------------------------------------------------|----------------------------------------------|
| Flat top-level decls (`Body`, `BodyAddress`, …)  | `nameRegistry` + `namePrefix`                |
| Nested decls inside each parent (idiomatic K/S)  | `inlineTypes: true`                          |
| TypeScript anonymous inline literals             | `inlineTypes: true` (TS-specific mechanism)  |

---

## x-named-type — referencing external types verbatim

The `x-named-type` keyword lets you tell ajsc to emit a bare reference to an already-defined external type instead of inlining or extracting it from the schema.

### When to use it

Use `x-named-type` when a property's type is defined elsewhere (a shared library, a hand-written class, a type from another emit call) and you want ajsc to reference it without redeclaring it.

### Usage

Set `"x-named-type": "TypeName"` on any schema node. ajsc short-circuits that node to a verbatim reference — the schema body (`type`, `properties`, `$ref`, etc.) is intentionally ignored.

```json
{
  "type": "object",
  "title": "Thread",
  "properties": {
    "author":    { "x-named-type": "Message" },
    "lastReply": { "x-named-type": "Message" },
    "replies":   { "type": "array", "items": { "x-named-type": "Message" } },
    "id":        { "type": "string" }
  }
}
```

**TypeScript output:**
```ts
export type Thread = { author?: Message; lastReply?: Message; replies?: Array<Message>; id?: string; };
```

**Kotlin output:**
```kotlin
@Serializable
data class Thread(
  val author: Message? = null,
  val lastReply: Message? = null,
  val replies: List<Message>? = null,
  val id: String? = null,
)
```

**Swift output:**
```swift
public struct Thread: Codable {
  public let author: Message?
  public let lastReply: Message?
  public let replies: [Message]?
  public let id: String?
}
```

### At the root

When `x-named-type` appears at the root level, ajsc emits a type alias:

- TypeScript: `export type Root = Message;`
- Kotlin: `typealias Root = Message`
- Swift: `public typealias Root = Message`

### Reporting referenced names

Referenced names are collected in `referencedNamedTypes` on the emit result (deduped and sorted). ajsc does **not** declare these types — the caller is responsible for importing or defining them:

```ts
const { code, referencedNamedTypes } = emitTypescript(schema);
// referencedNamedTypes: ["Message"]
// → caller must import or declare Message separately
```

### Constraints

- The value must be a non-empty string (the type name to emit verbatim).
- The schema body is ignored — ajsc never inlines or extracts the node.
- `Message` will **not** appear in `extractedTypeNames` (ajsc did not declare it).

### Limitations

- **Name collisions are not disambiguated.** If an `x-named-type` value matches a
  name ajsc generates for a sibling structural type (e.g. you reference
  `"Profile"` while a sibling object would also extract as `Profile`), ajsc emits
  the generated declaration and the reference resolves to *that* type — the two
  are silently merged. Keep `x-named-type` names distinct from the names ajsc
  derives from your property/path structure. Both names appearing in
  `extractedTypeNames` **and** `referencedNamedTypes` is the signal that a
  collision occurred.

---

## Working with the IR directly

If you need the intermediate representation (e.g., to write a custom emitter), use `JSONSchemaConverter`:

```ts
import { JSONSchemaConverter } from "ajsc/ir";

const ir = new JSONSchemaConverter({ type: "string" }).irNode;
// { type: "string", path: "", constraints: {} }
```

`IRNode` shape is documented in `ajsc/types`.

## Building a custom language emitter

To target a new language, extend `BaseConverter`:

```ts
import { BaseConverter, type LanguageProfile } from "ajsc/converter";

class DartConverter extends BaseConverter {
  protected readonly languageProfile: LanguageProfile = {
    language: "dart",
    // ... per-language overrides
  };
  // ... implement generateObjectType and the emission orchestration
}
```

See `docs/architecture/README.md` for the layered design — `BaseConverter`, the `LanguageProfile` pattern, and the per-language helper module conventions used by Kotlin and Swift.

---

## Migrating from v6 to v7

### Breaking changes

These are output changes for existing consumers. They're behavioral fixes, not regressions, but if you've been depending on the older shapes you'll see diffs:

- **`$ref: "#"` recursion** in TypeScript now emits direct self-reference (`Array<Tree>`) rather than a buggy `Child` extraction with `Array<any>` recursion.
- **`anyOf` merge** preserves the parent's `required` flag. A required schema field that gets anyOf-merged is now non-optional in output (`actor: Actor` instead of `actor?: Actor`).
- **`oneOf` variant naming** in TypeScript now uses discriminator-derived names (`WrapperOuter`, `ScalarOuter`) rather than path-collision suffix names (`Outer`, `OuterType`). Kotlin and Swift always used the discriminator-derived form; this brings TS into alignment.
- **`format: int32`** is now honored: emits `Int` (Kotlin) and `Int32` (Swift) where previously these always emitted `Long`/`Int64`. `format: int64` and unset format both still emit `Long`/`Int64`.
- **Schema-level `default`** is now emitted in Kotlin and Swift property declarations for primitive defaults (`val foo: String = "x"`). TS output is unchanged.
- **Swift acronym preservation**: enum case names for all-uppercase tokens (`US`, `URL`, `ID`) are no longer lowerCamelCased (`uS`/`uRL`/`iD`). They're preserved as-is.
- **`./converter` extension API** has been heavily refactored. If you subclassed `BaseConverter` to override hook methods, see the new `LanguageProfile` pattern in `docs/architecture/README.md`. Most legacy hook overrides should migrate to a single `protected readonly languageProfile` field.

### New features

- **Kotlin and Swift converters.** First-class language targets, not just IR.
- **Top-level emit functions.** `emitTypescript`, `emitKotlin`, `emitSwift` from `"ajsc"`.
- **`./converter` subpath** for downstream extension authors.
- **Root subpath restored.** `import { TypescriptConverter } from "ajsc"` works again (was subpath-only in v6).

### Migrating v5 → v7

If you're still on v5, the v6 → v7 changes apply on top of these:

```diff
- import { TypescriptConverter, JSONSchemaConverter } from "ajsc";
+ import { TypescriptConverter } from "ajsc";              // root entry restored
+ import { JSONSchemaConverter } from "ajsc/ir";           // moved to subpath in v6
```

---

## Architecture

For contributors and downstream extension authors, see [`docs/architecture/README.md`](./docs/architecture/README.md) — the data flow, layered class hierarchy, `LanguageProfile` pattern, per-language module structure, and the steps to add a new language target.

## License

MIT
