Skip to content

Schemas & Types ​

The generator converts TypeScript types into JSON Schema components for the OpenAPI spec. Understanding the resolution pipeline helps you get the most out of automatic schema generation.

Resolution pipeline ​

When the generator encounters a type (from @SwaggerResponse, @Body(), @SwaggerRequestBody, etc. — the accepted union is exported as TSwaggerConfigType), it resolves it through the following pipeline:

InputResult
Class with static toJsonSchema()Named component in #/components/schemas/
Instance with toJsonSchema()Named component
Plain object ({ type: 'string', ... })Inline JSON Schema
String, Number, Boolean{ type: 'string' }, { type: 'number' }, { type: 'boolean' }
Date{ type: 'string', format: 'date-time' }
Array{ type: 'array' }
Object{ type: 'object' }
[SomeType] (array literal){ type: 'array', items: <resolved SomeType> }
Literal string/number/boolean{ type, const: value }
Zero-arg functionInvoked; the return value is resolved recursively

Type-name strings are a shorthand

The string literals 'string', 'number', 'boolean', 'integer', 'object', and 'array' resolve to { type: <value> } (a type shorthand) — not to a const literal. Any other string becomes { const: value, type: 'string' }.

Atscript types ​

The recommended approach is to use Atscript .as files. The TypeScript plugin generates classes with static toJsonSchema(), so the swagger generator picks them up automatically:

as
// types/User.as

@label "User"
export interface UserDto {
  @label "User ID"
  id: string

  @label "Display name"
  name: string

  @label "Email address"
  @expect.pattern "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "u", "Invalid email"
  email: string

  @label "Roles"
  roles: string[]
}

This produces a UserDto class that the generator registers as #/components/schemas/UserDto. Everywhere you use UserDto — in responses, request bodies, parameters — it appears as { $ref: '#/components/schemas/UserDto' }.

Schema deduplication ​

The generator uses a WeakMap to track type references. The same class used in multiple places always produces a single component with multiple $ref pointers:

ts
@SwaggerResponse(UserDto)        // → $ref: '#/components/schemas/UserDto'
@Get(':id')
find() { /* ... */ }

@SwaggerResponse([UserDto])      // → { type: 'array', items: { $ref: '#/components/schemas/UserDto' } }
@Get()
list() { /* ... */ }

@SwaggerResponse(201, UserDto)   // → same $ref, no duplicate
@Post()
create(@Body() dto: CreateUserDto) { /* ... */ }

When two different classes share the same name (e.g., User from different modules), the generator appends a numeric suffix: User, User_1, User_2.

$defs hoisting ​

When a schema's toJsonSchema() output includes $defs (named sub-schemas referenced via $ref: '#/$defs/Name'), the generator automatically hoists them into #/components/schemas/ and rewrites all $ref paths.

This commonly occurs with:

  • Discriminated unions — Atscript unions with a discriminator property
  • Nested types — Types that reference other named types internally
  • Recursive types — Self-referencing schemas

Example: an Atscript discriminated union:

as
export interface Dog {
  petType: "dog"
  name: string
  breed: string
}

export interface Cat {
  petType: "cat"
  name: string
  indoor: boolean
}

export type Pet = Dog | Cat

Pet.toJsonSchema() produces:

json
{
  "oneOf": [
    { "$ref": "#/$defs/Dog" },
    { "$ref": "#/$defs/Cat" }
  ],
  "discriminator": {
    "propertyName": "petType",
    "mapping": { "dog": "#/$defs/Dog", "cat": "#/$defs/Cat" }
  },
  "$defs": {
    "Dog": { "type": "object", "properties": { ... } },
    "Cat": { "type": "object", "properties": { ... } }
  }
}

The generator transforms this into:

  • #/components/schemas/Pet — oneOf with rewritten $refs
  • #/components/schemas/Dog — hoisted from $defs
  • #/components/schemas/Cat — hoisted from $defs

Refs are rewritten at any nesting depth within the root schema tree — array items, object properties, oneOf/allOf/anyOf branches, discriminator.mapping — so Pet[] correctly references the same component schemas.

Sibling refs inside hoisted defs

Refs are not rewritten inside a hoisted definition: if Dog itself contained $ref: '#/$defs/Cat', that ref would remain as a dangling #/$defs/Cat in the hoisted Dog component. Keep cross-references in the root schema tree, or pre-resolve them in your toJsonSchema() output.

Auto-generated examples ​

toExampleData() ​

If a type exposes a static toExampleData() method, the generator calls it to populate the example field in the component schema:

ts
class UserDto {
  static toJsonSchema() {
    return {
      type: 'object',
      properties: {
        name: { type: 'string' },
        email: { type: 'string', format: 'email' },
      },
      required: ['name', 'email'],
    }
  }

  static toExampleData() {
    return { name: 'Alice', email: 'alice@example.com' }
  }
}

Atscript @meta.example ​

Atscript types can generate toExampleData() automatically from @meta.example annotations. Enable it in your Atscript config:

ts
// atscript.config.ts
import { defineConfig } from '@atscript/core'
import tsPlugin from '@atscript/typescript'

export default defineConfig({
  entries: ['src/**/*.as'],
  plugins: [
    tsPlugin({
      jsonSchema: 'bundle',
      exampleData: true,
    }),
  ],
})

With this enabled, every .as type with @meta.example annotations exposes toExampleData(), and the generator picks it up automatically. See the Atscript docs for details.

Example priority ​

When multiple sources provide an example, this priority order applies:

  1. example field already present in toJsonSchema() output (highest)
  2. @SwaggerExample decorator on the DTO class
  3. toExampleData() method (lowest — auto-generation fallback)

Metadata on schemas ​

Moost core decorators on DTOs are reflected in the generated component schemas:

DecoratorSchema field
@Label('...')title
@Description('...')description
@Id('...')title (fallback)
ts
@Label('User')
@Description('Represents a registered user in the system')
class UserDto {
  static toJsonSchema() { /* ... */ }
}

Generated component:

json
{
  "UserDto": {
    "title": "User",
    "description": "Represents a registered user in the system",
    "type": "object",
    "properties": { ... }
  }
}

Array shorthand ​

Wrap a type in an array literal to create an array schema:

ts
@SwaggerResponse([UserDto])
@Get()
list() {
  // Schema: { type: 'array', items: { $ref: '#/components/schemas/UserDto' } }
}

This works anywhere a type is accepted — responses, request bodies, parameters.

Lazy resolution ​

Pass a zero-argument function to defer schema resolution:

ts
@SwaggerResponse(() => UserDto)
@Get(':id')
find() { /* ... */ }

This is useful for avoiding circular import issues — the function is invoked at mapping time, after all modules are loaded.

Released under the MIT License.