Custom Primitives
Primitives are the fundamental scalar types in Atscript — string, number, boolean, decimal, and their semantic extensions like string.email or number.int. Plugins can add new primitive types and extensions that work identically to built-in ones: they appear in IntelliSense, carry constraint annotations, and generate appropriate type tags at runtime.
For a first plugin, keep the scope small:
- start with one scalar extension like
geo.latitude - attach validation through built-in
@expect.*annotations via the primitive'sannotationsmap - only reach for object, tuple, or phantom primitives when your plugin really needs them
What Primitives Are
A primitive in Atscript has:
- An underlying type — one of the final scalar types (
string,number,boolean,decimal,void,null,phantom) or a complex type definition - Optional documentation — shown in hover tooltips in the editor
- Optional annotations — applied automatically to every use of the primitive (e.g.,
expect.min: 0) - Optional semantic tags — string identifiers for runtime type discrimination
- Optional extensions — sub-primitives accessed via dot notation (e.g.,
string.email)
When a primitive has extensions, it becomes a namespace. string is a usable type on its own, and string.email is a more specific variant that inherits everything from string plus its own annotations.
The TPrimitiveConfig Interface
Primitives are defined with TPrimitiveConfig:
interface TPrimitiveConfig {
type?: TPrimitiveTypeDef
documentation?: string
tags?: string[]
isContainer?: boolean
annotations?: Record<string, TPrimitiveAnnotationValue>
extensions?: Record<string, Partial<TPrimitiveConfig>>
}2
3
4
5
6
7
8
| Field | Type | Description |
|---|---|---|
type | TPrimitiveTypeDef | Underlying scalar or complex type. Inherited from parent if omitted. |
documentation | string | Markdown text shown in IntelliSense. Inherited from parent if omitted. |
tags | string[] | Semantic tags for runtime discrimination (e.g., ['email']). |
isContainer | boolean | If true, the primitive itself cannot be used — one of its extensions must be chosen. |
annotations | Record<string, TPrimitiveAnnotationValue> | Annotations applied automatically wherever this primitive is used. Merged with parent's map. |
extensions | Record<string, Partial<TPrimitiveConfig>> | Sub-primitives accessible via dot notation. |
The annotations Map
Each entry maps a fully-qualified annotation name (the same name used in .as files) to its value. The value shape matches the annotation's argument list:
type TPrimitiveAnnotationArg = string | number | boolean
type TPrimitiveAnnotationArgs = Record<string, TPrimitiveAnnotationArg>
type TPrimitiveAnnotationValue =
| boolean // no-arg annotation (e.g., 'expect.int': true)
| string // single string arg
| number // single number arg (e.g., 'expect.min': 0)
| TPrimitiveAnnotationArgs // multi named args (e.g., { pattern: '...', message: '...' })
| (TPrimitiveAnnotationArg | TPrimitiveAnnotationArgs)[] // multiple occurrences (with multiple: true)2
3
4
5
6
7
8
Annotations declared in a primitive's annotations map are identical to writing the same annotation in .as source on every field that uses the primitive. They participate in normal validation and inheritance.
The Final Scalar Types
TPrimitiveTypeFinal is the set of underlying scalar kinds a primitive can resolve to:
type TPrimitiveTypeFinal =
| 'string'
| 'number'
| 'boolean'
| 'decimal'
| 'void'
| 'null'
| 'phantom'2
3
4
5
6
7
8
string,number,boolean— the standard scalarsdecimal— string-backed arbitrary-precision numeric (^[+-]?\d+(\.\d+)?$). See Validation Specification.void,null— terminal value typesphantom— non-data primitive used for runtime-discoverable metadata fields (see Phantom Primitives)
never is also a valid primitive name (no type field — represents the impossible type).
Adding Primitives via config()
Register primitives in your plugin's config() hook:
import { createAtscriptPlugin } from '@atscript/core'
export const geoPlugin = () =>
createAtscriptPlugin({
name: 'geo',
config() {
return {
primitives: {
geo: {
isContainer: true,
documentation: 'Geographic data types',
extensions: {
latitude: {
type: 'number',
documentation: 'Latitude coordinate (-90 to 90)',
tags: ['latitude'],
annotations: { 'expect.min': -90, 'expect.max': 90 },
},
longitude: {
type: 'number',
documentation: 'Longitude coordinate (-180 to 180)',
tags: ['longitude'],
annotations: { 'expect.min': -180, 'expect.max': 180 },
},
postalCode: {
type: 'string',
documentation: 'Postal/ZIP code',
tags: ['postalCode'],
annotations: {
'expect.pattern': {
pattern: '^[A-Z0-9 -]{3,10}$',
flags: 'i',
message: 'Invalid postal code format',
},
},
},
},
},
},
}
},
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Usage in .as files:
export interface Location {
@meta.label "Latitude"
lat: geo.latitude
@meta.label "Longitude"
lng: geo.longitude
@meta.label "ZIP Code"
zip: geo.postalCode
}2
3
4
5
6
7
8
9
10
Real-World Example: Built-In string.email
The built-in string primitive ships with several extensions, including string.email:
// Shape used by Atscript's built-in primitives (excerpt)
primitives: {
string: {
type: 'string',
documentation: 'Represents textual data.',
extensions: {
email: {
documentation: 'Represents an email address.',
annotations: {
'expect.pattern': {
pattern: '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$',
message: 'Invalid email format.',
},
},
},
uuid: {
documentation: 'Represents a UUID.',
annotations: {
'expect.pattern': {
pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
flags: 'i',
message: 'Invalid UUID format.',
},
},
},
},
},
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Pattern strings, not regex literals
expect.pattern accepts the pattern as a string (matching how .as source writes it), with an optional flags field. JavaScript RegExp literals are not the wire form — write pattern: '^[A-Z]+$', not /^[A-Z]+$/.
Complex Type Definitions
The type field accepts TPrimitiveTypeDef, which can be:
Scalar Types
A plain string for simple types:
type: 'string' // textual data
type: 'number' // numeric data
type: 'boolean' // true/false
type: 'decimal' // arbitrary-precision string-backed numeric
type: 'void' // no value
type: 'null' // null value
type: 'phantom' // metadata-only (excluded from generated types and validation)2
3
4
5
6
7
Array Type
An array of a given element type:
type: { kind: 'array', of: 'number' } // number[]
type: { kind: 'array', of: 'string' } // string[]2
Union Type
One of several possible types:
type: {
kind: 'union',
items: ['string', 'number'], // string | number
}2
3
4
Intersection Type
A combination of types:
type: {
kind: 'intersection',
items: ['string', 'number'], // string & number
}2
3
4
Tuple Type
A fixed-length array with typed positions:
type: {
kind: 'tuple',
items: ['number', 'number'], // [number, number]
}2
3
4
Object Type
A structured type with named properties:
type: {
kind: 'object',
props: {
x: 'number',
y: 'number',
label: { kind: 'final', value: 'string', optional: true },
},
propsPatterns: {},
}2
3
4
5
6
7
8
9
Optional Wrapper
Any final scalar can be wrapped as optional:
type: { kind: 'final', value: 'string', optional: true } // string | undefinedComposing Complex Types
Complex types nest arbitrarily. For example, a GeoJSON point:
geo: {
extensions: {
point: {
type: {
kind: 'object',
props: {
type: 'string', // "Point"
coordinates: {
kind: 'tuple',
items: ['number', 'number'], // [longitude, latitude]
},
},
propsPatterns: {},
},
documentation: 'GeoJSON Point with [longitude, latitude] coordinates',
},
},
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Annotation-Backed Validation
Primitives don't run validation themselves. Instead, they declare annotations in their annotations map, and the runtime validator enforces those annotations exactly as if they had been written in .as source.
The built-in @expect.* and @meta.* annotations cover the common cases:
For String Types
annotations: {
'expect.pattern': { pattern: '^[a-z0-9-]+$', message: 'Invalid format' },
'expect.minLength': 1,
'expect.maxLength': 255,
'meta.required': true, // non-empty, non-whitespace
}2
3
4
5
6
Multiple patterns are expressed as an array — the value must match at least one:
annotations: {
'expect.pattern': [
{ pattern: '^\\d{4}-\\d{2}-\\d{2}$', message: 'Invalid date format' },
{ pattern: '^\\d{2}/\\d{2}/\\d{4}$', message: 'Invalid date format' },
],
}2
3
4
5
6
For Number Types
annotations: {
'expect.min': 0,
'expect.max': 100,
'expect.int': true,
}2
3
4
5
For Boolean Types
annotations: {
'meta.required': true, // must be true (e.g. "accept terms" checkbox)
}2
3
For full semantics (evaluation order, error formats, partial mode, etc.) see Validation Specification.
Container Primitives and Inheritance
When isContainer: true, the primitive itself can't be used directly — only its extensions can. This is useful for creating namespaces:
config() {
return {
primitives: {
color: {
isContainer: true,
documentation: 'Color value types',
extensions: {
hex: {
type: 'string',
documentation: 'Hex color (#RGB or #RRGGBB)',
annotations: {
'expect.pattern': {
pattern: '^#[\\da-f]{3,8}$',
flags: 'i',
},
},
},
rgb: {
type: { kind: 'tuple', items: ['number', 'number', 'number'] },
documentation: 'RGB color as [r, g, b]',
},
name: {
type: 'string',
documentation: 'Named CSS color',
},
},
},
},
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Using color directly produces a compiler error:
// Error: 'color' is a container — use color.hex, color.rgb, or color.name
background: color
// OK
background: color.hex2
3
4
5
Inheritance Rules
Extensions inherit from their parent:
type— inherited if not specified (sostring.emailhastype: 'string')documentation— inherited if not specifiedannotations— merged with parent's map (child entries are added on top)tags— inherited from parent
This means you can define a base type once and specialize it:
primitives: {
id: {
type: 'string',
documentation: 'An identifier string',
annotations: { 'expect.minLength': 1 },
extensions: {
uuid: {
documentation: 'UUID v4 identifier',
annotations: {
'expect.pattern': {
pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
flags: 'i',
},
},
// inherits type: 'string' and expect.minLength: 1
},
slug: {
documentation: 'URL-safe slug',
annotations: {
'expect.pattern': { pattern: '^[a-z0-9-]+$' },
'expect.maxLength': 100,
},
// inherits type: 'string' and expect.minLength: 1
},
},
},
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Nested Extensions
Extensions can have their own extensions, creating multi-level hierarchies:
primitives: {
number: {
type: 'number',
extensions: {
int: {
annotations: { 'expect.int': true },
extensions: {
positive: { annotations: { 'expect.min': 0 } },
negative: { annotations: { 'expect.max': 0 } },
},
},
},
},
}2
3
4
5
6
7
8
9
10
11
12
13
14
This gives you number.int, number.int.positive, and number.int.negative — each inheriting and accumulating annotations from the levels above.
Semantic Tags
Tags are string identifiers attached to primitive instances. They give runtime code a way to discriminate between primitives that share the same underlying scalar type. For example, string.email and string.uuid both have type: 'string' — but their tags (['email'] vs ['uuid']) let runtime code tell them apart without inspecting the primitive name.
primitives: {
currency: {
type: 'number',
tags: ['currency'],
documentation: 'A monetary value',
extensions: {
usd: { tags: ['usd'], documentation: 'US Dollars' },
eur: { tags: ['eur'], documentation: 'Euros' },
},
},
}2
3
4
5
6
7
8
9
10
11
Tags are inherited — currency.usd carries both the 'currency' tag from its parent and its own 'usd' tag. Your code generator should make these tags available at runtime so that consuming code can query them (e.g., to choose a currency formatter based on the tag).
Phantom Primitives
Primitives with type: 'phantom' represent non-data properties — fields that carry metadata and annotations but do not contribute to the data shape of a structure. They exist for runtime discovery (e.g., a form renderer or code generator can find them in the type tree) but they are not part of the actual data model.
Purpose and Intent
The core idea: a structure can contain fields that describe UI elements, layout hints, or actions alongside real data fields. These phantom fields:
- Should not appear in the data type — they don't represent actual data that gets stored, transmitted, or validated. A language plugin should exclude them from the generated type/class shape (or mark them as non-data in whatever way the target language supports).
- Should be skipped by validation — since they carry no data, validators should ignore them.
- Should be discoverable at runtime — the whole point is that code walking the type tree can find these fields and their annotations. A form renderer, for example, can use them to insert dividers, headings, or action buttons between real data fields.
How to Handle Phantom Types in Your Plugin
When building a code generator, you need to decide how your target language handles phantom fields. The key principle: phantom fields must not affect the data contract. Some approaches:
- Exclude from the generated type entirely — the simplest approach. The field exists only in the runtime type metadata, not in the language-level type.
- Include but mark as non-data — in languages with richer type systems, you might use a special marker type, a decorator, or a comment to indicate the field is phantom.
- Separate data type from metadata type — generate two representations: a clean data type without phantom fields, and a full metadata type that includes them.
The important thing is that serialization, deserialization, and validation of real data should never encounter phantom fields.
Example
primitives: {
ui: {
type: 'phantom',
isContainer: true,
documentation: 'Non-data UI elements for form rendering',
extensions: {
divider: { documentation: 'Visual divider between sections' },
paragraph: { documentation: 'Informational text block' },
action: { documentation: 'Interactive element (button, link)' },
},
},
}2
3
4
5
6
7
8
9
10
11
12
export interface RegistrationForm {
@meta.label "Full Name"
name: string.required
@meta.label "By signing up you agree to our terms."
terms: ui.paragraph
@meta.label "Submit"
@ui.component "primary-button"
submit: ui.action
}2
3
4
5
6
7
8
9
10
11
Here terms and submit are phantom — they describe UI elements, not data. The actual data shape of RegistrationForm has only one field: name. But a form renderer walking the full type tree finds all three fields in source order and can render a text block and a button alongside the input field.
You can detect phantom types in your code generator by checking if a property's resolved type is a primitive with type: 'phantom'. Use doc.unwindType() to resolve references, then inspect the primitive config.
Next Steps
- Custom Annotations — define your own annotation specs with validation
- Building a Code Generator — generate output files from your custom types