npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ajsc

v7.3.0

Published

Another Json-Schema Converter

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 ajsc
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:

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 (entriesEntry). | | 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. stringstring, integernumber, 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: int32Int | | 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: int32Int32 | | 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 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:

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 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:

- 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 — the data flow, layered class hierarchy, LanguageProfile pattern, per-language module structure, and the steps to add a new language target.

License

MIT