Skip to content

Custom Annotations

Annotations are the metadata layer in Atscript. They can carry labels, validation rules, API hints, UI hints, or any other model-level information your plugin needs.

For a first plugin, you usually only need:

  • a name
  • a nodeType
  • zero or more typed arguments

Start there. Validation callbacks, merge strategies, and AST mutation are useful later, but they are not required for a useful first annotation.

The AnnotationSpec Class

Every annotation is defined by an AnnotationSpec instance:

typescript
import { AnnotationSpec } from '@atscript/core'

new AnnotationSpec({
  description: 'Mark field as searchable',
  nodeType: ['prop'],
  argument: { name: 'weight', type: 'number', optional: true },
  multiple: false,
  mergeStrategy: 'replace',
})

TAnnotationSpecConfig Options

OptionTypeDefaultDescription
descriptionstringDocumentation shown in IntelliSense hover
nodeTypeTNodeEntity[]Where annotation can appear: 'interface', 'type', 'prop'
argumentobject | object[]Argument definition(s)
multiplebooleanfalseAllow the annotation to appear more than once on the same node
mergeStrategy'replace' | 'append''replace'How values combine during annotation inheritance
defTypestring[]Restrict to specific value types. See Available defType values.
validatefunctionCustom validation at parse time
modifyfunctionAST mutation after validation

Registering Annotations via config()

Annotations are registered in a nested tree structure. The tree path becomes the dot-notation name:

typescript
import { createAtscriptPlugin, AnnotationSpec } from '@atscript/core'

export const apiPlugin = () =>
  createAtscriptPlugin({
    name: 'api',
    config() {
      return {
        annotations: {
          api: {
            // @api.* namespace
            endpoint: new AnnotationSpec({
              // @api.endpoint
              description: 'REST endpoint for this interface',
              nodeType: ['interface'],
              argument: { name: 'path', type: 'string' },
            }),
            method: new AnnotationSpec({
              // @api.method
              description: 'HTTP method',
              nodeType: ['interface'],
              argument: {
                name: 'method',
                type: 'string',
                values: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
              },
            }),
            field: {
              // @api.field.* sub-namespace
              readonly: new AnnotationSpec({
                // @api.field.readonly
                description: 'Field is read-only in API responses',
                nodeType: ['prop'],
              }),
              writeOnly: new AnnotationSpec({
                // @api.field.writeOnly
                description: 'Field is accepted in requests but excluded from responses',
                nodeType: ['prop'],
              }),
            },
          },
        },
      }
    },
  })

The nesting depth is arbitrary — @api.field.readonly comes from annotations.api.field.readonly.

Annotation Arguments

Each argument is defined with TAnnotationArgument:

typescript
interface TAnnotationArgument {
  name: string
  type: 'string' | 'number' | 'boolean' | 'ref' | 'query'
  optional?: boolean
  description?: string
  values?: string[] // Enum — restrict to specific values
}

The argument types correspond to the tokens accepted in .as source:

typeAccepts
'string'Quoted string literal ("text")
'number'Numeric literal (42, -1.5)
'boolean'Identifier true / false
'ref'Bare identifier referencing another type (e.g. User)
'query'Backtick-delimited query expression — used by DB plugins for SQL-like filter syntax

No Arguments (Flag Annotation)

Omit argument entirely:

typescript
new AnnotationSpec({
  description: 'Mark field as deprecated',
  nodeType: ['prop', 'interface'],
})

Usage: @api.deprecated (no arguments)

Single Argument

Pass a single object:

typescript
new AnnotationSpec({
  description: 'Display label for the field',
  argument: { name: 'text', type: 'string' },
})

Usage: @meta.label "Full Name"

Multiple Arguments

Pass an array of objects. Arguments are positional:

typescript
new AnnotationSpec({
  description: 'Vector search index',
  argument: [
    { name: 'dimensions', type: 'number' },
    {
      name: 'similarity',
      type: 'string',
      optional: true,
      values: ['cosine', 'euclidean', 'dotProduct'],
    },
    { name: 'indexName', type: 'string', optional: true },
  ],
})

Usage: @search.vector 512, "cosine", "my-index"

Enum Values

The values field restricts which strings are accepted — the compiler reports an error for any other value:

typescript
new AnnotationSpec({
  argument: {
    name: 'strategy',
    type: 'string',
    values: ['replace', 'merge'],
  },
})

Usage: @patch.strategy "replace" (accepted) vs @patch.strategy "upsert" (error)

Merge Strategies

When annotations are inherited through type references, the merge strategy controls how values combine:

'replace' (Default)

The annotation on the child/inner type overwrites the parent's:

typescript
new AnnotationSpec({
  mergeStrategy: 'replace', // default
  argument: { name: 'value', type: 'string' },
})
atscript
interface Base {
    @meta.label "Base Name"
    name: string
}

annotate Base as Extended {
    @meta.label "Extended Name"    // overwrites "Base Name"
    name
}

'append'

Values accumulate — both parent and child annotations are preserved as an array:

typescript
new AnnotationSpec({
  multiple: true,
  mergeStrategy: 'append',
  argument: { name: 'tag', type: 'string' },
})
atscript
interface Base {
    @tag "searchable"
    name: string
}

annotate Base as Tagged {
    @tag "sortable"           // both "searchable" and "sortable" are kept
    name
}

TIP

mergeStrategy: 'append' almost always pairs with multiple: true — otherwise the base annotation would error on duplicates.

Custom Validation

For validation logic beyond type checks and argument counts, provide a validate function:

typescript
validate(mainToken: Token, args: Token[], doc: AtscriptDoc): TMessages | undefined
ParameterDescription
mainTokenThe annotation token (e.g., @api.endpoint). Access the parent node via mainToken.parentNode.
argsArray of argument tokens. Each has .text (raw value), .type (token type), .range (source location).
docThe AtscriptDoc instance for resolving types and querying the document.

Return an array of diagnostic messages, or undefined if valid:

typescript
interface TMessage {
  severity: 1 | 2 | 3 | 4 // 1=Error, 2=Warning, 3=Info, 4=Hint
  message: string
  range: { start: Position; end: Position }
}

TIP

Built-in validation runs before your validate callback. The AnnotationSpec class automatically checks multiple, nodeType, argument count, argument types, values, and defType. Your callback only needs to handle domain-specific logic.

Example: Validate a Required Sibling Property

A @store.collection annotation that requires an id field of type string or number:

typescript
new AnnotationSpec({
  nodeType: ['interface'],
  validate(token, args, doc) {
    const parent = token.parentNode
    if (!isInterface(parent) || !parent.props.has('id')) {
      return [
        {
          severity: 1,
          message: '@store.collection requires an "id" property',
          range: token.range,
        },
      ]
    }

    const errors = []
    const idProp = parent.props.get('id')!

    if (idProp.token('optional')) {
      errors.push({
        severity: 1,
        message: '"id" cannot be optional on a @store.collection',
        range: idProp.token('identifier')!.range,
      })
    }

    // Resolve the property type and check it is string or number
    let def = idProp.getDefinition()
    if (isRef(def)) {
      def = doc.unwindType(def.id!, def.chain)?.def || def
    }
    if (isPrimitive(def) && !['string', 'number'].includes(def.type!)) {
      errors.push({
        severity: 1,
        message: '"id" must be of type string or number',
        range: idProp.token('identifier')!.range,
      })
    }

    return errors.length > 0 ? errors : undefined
  },
})

Example: Validate Field Type

Restrict an annotation to object or array fields:

typescript
new AnnotationSpec({
  nodeType: ['prop'],
  argument: {
    name: 'strategy',
    type: 'string',
    values: ['replace', 'merge'],
  },
  validate(token, args, doc) {
    const field = token.parentNode!
    const definition = field.getDefinition()
    if (!definition) return

    // Resolve references
    let def = definition
    if (isRef(def)) {
      def = doc.unwindType(def.id!, def.chain)?.def || def
    }

    if (!isStructure(def) && !isInterface(def) && !isArray(def)) {
      return [
        {
          severity: 1,
          message: 'Patch strategy requires an object or array type',
          range: token.range,
        },
      ]
    }
  },
})

Simple Alternative: defType

For basic type restrictions, use defType instead of a full validate function:

typescript
new AnnotationSpec({
  description: 'Decimal precision for numeric display',
  defType: ['number'], // only valid on number fields
  argument: { name: 'digits', type: 'number' },
})

Available defType values:

  • Final scalar kinds: 'string', 'number', 'boolean', 'decimal', 'phantom', 'null', 'void', 'never'
  • Composite kinds: 'object', 'array', 'union', 'intersection'

'object' matches both interfaces and inline structures; 'union' / 'intersection' match group nodes.

AST Modification with modify()

The modify hook runs after successful validation and can mutate the AST. This is a powerful feature for plugins that need to inject computed properties or restructure the parsed document.

typescript
modify(mainToken: Token, args: Token[], doc: AtscriptDoc): void

Example: Auto-Add an ID Property

An @store.collection annotation that automatically adds an id property when the interface doesn't already have one:

typescript
new AnnotationSpec({
  nodeType: ['interface'],
  modify(token, args, doc) {
    const parent = token.parentNode
    const struc = parent?.getDefinition()
    if (isInterface(parent) && !parent.props.has('id') && isStructure(struc)) {
      struc.addVirtualProp({
        name: 'id',
        type: 'string',
        documentation: 'Primary identifier',
      })
    }
  },
})

Now every @store.collection interface automatically gets id: string without the author writing it explicitly:

atscript
@store.collection "users"
export interface User {
    // id: string — injected automatically
    email: string.email
    name: string
}

Example: Inject Timestamp Fields

A plugin that auto-adds created/updated timestamps:

typescript
new AnnotationSpec({
  description: 'Automatically add timestamp fields',
  nodeType: ['interface'],
  modify(token, args, doc) {
    const parent = token.parentNode
    const struc = parent?.getDefinition()
    if (isInterface(parent) && isStructure(struc)) {
      if (!parent.props.has('createdAt')) {
        struc.addVirtualProp({
          name: 'createdAt',
          type: 'number.timestamp',
          documentation: 'Creation timestamp',
        })
      }
      if (!parent.props.has('updatedAt')) {
        struc.addVirtualProp({
          name: 'updatedAt',
          type: 'number.timestamp',
          documentation: 'Last update timestamp',
        })
      }
    }
  },
})

TIP

modify runs once per annotation occurrence. If multiple: true and the annotation appears twice, modify runs twice. Make sure your modifications are idempotent (check before adding).

Complete Plugin Example

Here's a full plugin combining primitives and annotations for an API documentation system:

typescript
import { createAtscriptPlugin, AnnotationSpec, isInterface } from '@atscript/core'

export const openApiPlugin = () =>
  createAtscriptPlugin({
    name: 'openapi',
    config() {
      return {
        primitives: {
          openapi: {
            extensions: {
              date: {
                type: 'string',
                documentation: 'ISO 8601 date string (format: date)',
                tags: ['date'],
                annotations: {
                  'expect.pattern': {
                    pattern: '^\\d{4}-\\d{2}-\\d{2}$',
                    message: 'Expected ISO date format (YYYY-MM-DD)',
                  },
                },
              },
              dateTime: {
                type: 'string',
                documentation: 'ISO 8601 date-time string (format: date-time)',
                tags: ['dateTime'],
                annotations: {
                  'expect.pattern': {
                    pattern: '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}',
                    message: 'Expected ISO date-time format',
                  },
                },
              },
            },
          },
        },
        annotations: {
          openapi: {
            schema: new AnnotationSpec({
              description: 'OpenAPI schema name for this interface',
              nodeType: ['interface'],
              argument: { name: 'name', type: 'string', optional: true },
            }),
            tag: new AnnotationSpec({
              description: 'OpenAPI tag for grouping endpoints',
              nodeType: ['interface'],
              multiple: true,
              mergeStrategy: 'append',
              argument: { name: 'tag', type: 'string' },
            }),
            deprecated: new AnnotationSpec({
              description: 'Mark as deprecated in OpenAPI spec',
              nodeType: ['prop', 'interface'],
            }),
            example: new AnnotationSpec({
              description: 'Example value for OpenAPI documentation',
              nodeType: ['prop'],
              argument: { name: 'value', type: 'string' },
            }),
          },
        },
      }
    },
  })

Usage in .as files:

atscript
@openapi.schema "CreateUserRequest"
@openapi.tag "users"
export interface CreateUser {
    @meta.label "Email Address"
    @openapi.example "user@example.com"
    email: string.email

    @meta.label "Full Name"
    @openapi.example "Jane Doe"
    name: string.required

    @meta.label "Date of Birth"
    @openapi.example "1990-01-15"
    birthday?: openapi.date
}

Next Steps

Released under the MIT License.