ajsc
v7.3.0
Published
Another Json-Schema Converter
Maintainers
Readme
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.
npm install ajscimport { 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:
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
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)
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)
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
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
// 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 enums 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.
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:
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:
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.
{
"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:
export type Thread = { author?: Message; lastReply?: Message; replies?: Array<Message>; id?: string; };Kotlin output:
@Serializable
data class Thread(
val author: Message? = null,
val lastReply: Message? = null,
val replies: List<Message>? = null,
val id: String? = null,
)Swift output:
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:
const { code, referencedNamedTypes } = emitTypescript(schema);
// referencedNamedTypes: ["Message"]
// → caller must import or declare Message separatelyConstraints
- 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.
Messagewill not appear inextractedTypeNames(ajsc did not declare it).
Limitations
- Name collisions are not disambiguated. If an
x-named-typevalue matches a name ajsc generates for a sibling structural type (e.g. you reference"Profile"while a sibling object would also extract asProfile), ajsc emits the generated declaration and the reference resolves to that type — the two are silently merged. Keepx-named-typenames distinct from the names ajsc derives from your property/path structure. Both names appearing inextractedTypeNamesandreferencedNamedTypesis 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:
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:
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 buggyChildextraction withArray<any>recursion.anyOfmerge preserves the parent'srequiredflag. A required schema field that gets anyOf-merged is now non-optional in output (actor: Actorinstead ofactor?: Actor).oneOfvariant 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: int32is now honored: emitsInt(Kotlin) andInt32(Swift) where previously these always emittedLong/Int64.format: int64and unset format both still emitLong/Int64.- Schema-level
defaultis 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. ./converterextension API has been heavily refactored. If you subclassedBaseConverterto override hook methods, see the newLanguageProfilepattern indocs/architecture/README.md. Most legacy hook overrides should migrate to a singleprotected readonly languageProfilefield.
New features
- Kotlin and Swift converters. First-class language targets, not just IR.
- Top-level emit functions.
emitTypescript,emitKotlin,emitSwiftfrom"ajsc". ./convertersubpath 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:
- import { TypescriptConverter, JSONSchemaConverter } from "ajsc";
+ import { TypescriptConverter } from "ajsc"; // root entry restored
+ import { JSONSchemaConverter } from "ajsc/ir"; // moved to subpath in v6Architecture
For contributors and downstream extension authors, see 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
