Interfaces & Types
Atscript provides TypeScript-like syntax with annotations and semantic types.
Start With Interfaces
Most .as files are just interfaces with a few annotations and semantic types:
export interface User {
id: string
name: string
age: number
isActive: boolean
}Use an interface when you want a named object shape that can later be imported, validated, and inspected at runtime.
Nest Objects Naturally
Inline nested objects work well when the nested shape is local to one model:
export interface User {
id: string
profile: {
name: string
avatar?: string
}
settings: {
theme: 'light' | 'dark'
}
}If you want to reuse a nested shape across files, give it its own interface or type alias and import it.
Use Type Aliases For Reusable Values
Type aliases are useful for named primitives, unions, and reusable constraints:
@expect.minLength 3
@expect.maxLength 20
export type Username = string
export type Status = 'pending' | 'success' | 'error'
export type ID = string | numberLike interfaces, exported type aliases also exist at runtime and can be validated.
Common Property Patterns
Optional Properties
export interface Config {
name: string
bio?: string
}Arrays, Tuples, And Literals
export interface Data {
tags: string[]
coords: [number, number]
status: 'pending' | 'done'
}An array of a literal is written like any array: 'y'[] and 1[] (the same as ('y')[]). As in TypeScript, [] binds tighter than | and &: 'a' | 'b'[] is 'a' | ('b')[]; write ('a' | 'b')[] for an array of either.
Dynamic Keys
If a model needs open-ended keys, use wildcard or pattern properties:
export interface EnvConfig {
NODE_ENV: 'development' | 'production'
[/^PUBLIC_.*/]: string
}That is useful for configuration objects, custom metadata maps, or other flexible records.
Reuse Types By Reference
import { Address } from './address'
export interface User {
address: Address
friends: User[]
manager?: User
}You can reference other types, self-reference, and use arrays of references naturally.
A reference is the referenced type itself, not a copy: at runtime User.address reads its structure and annotations from Address, so an annotate Address { … } block reaches every place that uses Address. A property typed by a reference has its own ? and its own annotations on top of the type's.
A type alias without annotations is the type it names: type Account = User, type Account = (User) and aliases of aliases are User (the same runtime object), so a block on Account is a block on User. An alias with annotations of its own (@meta.description 'Account' type Account = User) is a type of its own whose annotations sit on top of User's.
Reference A Field (Type Paths)
A type path T.a.b names a field of another type, with that field's type and annotations:
export interface Order {
customerId: User.id
city?: User.address.city
}A path goes through unions. Each step keeps the union branches that have the property; null and other non-object branches have none. The same applies to a union written on a property (work?: Job | null), behind an alias (type MaybeUser = User | null), or produced by a merge with a union operand.
| Branches with the whole path | The path is |
|---|---|
| none | a compile error: Unknown member "…" |
| one | that field, as if the path had no union (MaybeUser.address.city is User.address.city) |
| two or more | the union of those fields, each keeping its own annotations |
A branch where the path stops deeper (Contact = Email | Phone with Contact.info.label, where only Email.info has label) is skipped; the path is an error only when no branch reaches its end.
Branches that reach the same field count once, when the same blocks apply at or below it. The same field is the same declared property, or a merged property with the same contributors. type Pair = User | User, type Member = Admin | User with Admin extends User, a property that one operand of a merge declares in every branch, and a property two operands merge in every branch (type Row = (Card | Note) & Base & Extra, with k in Base and Extra: Row.k) all give one field. A block on the union itself (annotate Member { address.city }) applies on every branch, so it is still one field. A block on some branches only, at or below the field (annotate Admin { address.city }), makes them different fields: the path is the union of the two, and each branch validates with its own annotations (an @expect.maxLength in that block checks its branch only). A block only on a step before the field (annotate Admin { address } for Member.address.city) does not count: it is still one field.
Blocks only change metadata, never structure. For structure — extends, merges (&) and when a merge is never, how a merge distributes over a union, the merge checks, and the type in the generated .d.ts — the positions that differ only by blocks are one type. With type Z = Member.address, annotate Admin { address } and interface Home extends Z { … }, Home gets the address's props whether or not the block exists, and Z & Address is Address. The same holds whether the block is in the same file or in another one.
Fields of the same type are one type for structure too, whichever properties they are. The runtime still keeps one item per field, each with its own annotations:
interface Address {
city?: string
}
interface Office {
@meta.label 'Office address'
address: Address
}
interface Home {
address?: Address
}
type Place = Office | Home
type Z = Place.address // structure: Address; runtime: Office.address | Home.address
type ZE = Z & { zip?: string } // Address's props and annotations, plus zipA merge or an extends built from such a path gets the type alone: Address's props and its own annotations, never a field's ('Office address' above), nor those of an annotated alias a field is typed with (@meta.label 'P' type P = Address). When the positions are one declared field that blocks split, it gets that field without the blocks: a block belongs to the path it was written on. The field annotations stay on the runtime items of Z itself. The result is the same in any union order.
A field can't reach itself through a type path. When a merged or inherited field is typed by a path back to that same field, the compiler reports Circular chain reference "M.c": it resolves to itself:
interface A {
c?: M.c
}
interface B {
c?: string
}
type M = A & B // Error: Circular chain reference "M.c": it resolves to itselfThe same holds through | null (c?: M.c | null), through extends (interface J extends A, B with c?: J.c) and through an annotate M as X copy (c?: X.c). Type the field directly instead (c?: string).
The blocks that count are those of every file connected to the path's file by imports, in either direction: the files it imports, the files importing them, and so on — the same files the generated code loads together. That includes a block in a sibling file that never imports the path's file, the one the compiler warns about with "this annotate applies only in builds including this file".
A block whose own path goes through a type path that it splits is a compile error: "A block's path can't cross the type path it splits." For example, with type U = T | Other, both work: W, and interface H { x?: U.work.h }, the block annotate T { work.h.x.y } is below T.work.h on one branch only, so it would split U.work.h into two fields, and it also lands through H.x, which is U.work.h. Where it lands would depend on the split it causes. The path counts as going through U.work.h by any route: a type alias of it, an array of it, U.work.h | null, another type path that names it (x?: Z.k with Z.k?: U.work.h), or an intersection that includes it (x?: U.work.h & E). Blocks that depend on each other count too: a block is an error when where it lands depends on a split that it causes, directly or through other blocks. To annotate the field on every branch, put the block on the union instead (annotate U { work.h.x.y }): it does not split U.work.h, so it builds. Annotating each branch with its own block (annotate T { ... } and annotate I9 { ... }) still gives an error for each block.
A path that names several fields is phantom only when every field is phantom. It is then one phantom field like any other (excluded from validation, JSON Schema and the .d.ts data type). At runtime its type is the fields' common phantom primitive (all ui.action gives ui.action, with its tags), or plain phantom when they differ; it carries none of the fields' annotations. A mix of phantom and data fields is a compile error.
In the generated .d.ts, each step of a path is TAtscriptField<T, "a"> from @atscript/typescript/utils: the field of every object branch of T that has it. A path through a union therefore has the TypeScript type of the union of its fields, and a step through an optional property does not fail on undefined. The path's runtime descriptor is typed as what the runtime holds (see Type paths in the .d.ts).
Advanced Composition
Interface Extends
interface BaseEntity {
id: string
createdAt: string.isoDate
}
interface Timestamped {
updatedAt: string
}
export interface Post extends BaseEntity, Timestamped {
title: string
body: string
}Rules:
- Own properties are added to the inherited ones.
- An inherited property is the parent's own property:
Post.idisBaseEntity.id, with every annotation it has, including those added by blocks ofBaseEntity. - A property several parents declare is merged, the last parent winning on the same annotation.
- Interface-level annotations are not inherited.
- Overriding a parent property in a child is not allowed. To add annotations to an inherited property without redeclaring it, use a mutating
annotateblock:annotate Post { @meta.label 'Post id' id }annotatesPost.idonly (Post's own property on top ofBaseEntity.id), andannotate BaseEntity { id }annotates the parent and every child. - Self-extends and circular extends are detected as errors.
- An interface can't extend a union of two or more branches that are not
null(a compile error: "An interface can't extend a union; use a merge (U & { … })"). A type path that names one field through several branches is not a union here, even when blocks make its positions different fields (see above). Use a merge instead:type Post = Content & { title: string }. Extending an alias ofUser | null(every branch but one isnull) is extendingUser.
Intersection Types
interface Timestamped {
createdAt: string
}
export type Post = {
title: string
} & TimestampedIntersections are useful when you want to combine types inline instead of declaring a new parent interface. Use type (not interface) for the intersection itself — interfaces cannot end with a trailing & Other.
An intersection of object types is merged into one object type, as in TypeScript, and extends merges its parents the same way.
The guarantee: an intersection L & R accepts exactly the values L and R both accept, in every validation engine (the walk, the interpreted pre-check, compiled checks) — except that the annotations of a property several members declare merge by priority (below), the shapes listed under Merges that resolve to never, and pattern properties. With unknownProps: 'error' / 'strip', a key is unknown only when no member declares it. A p?: L & R property also passes undefined and null, like any optional property.
Pattern properties are the exception. The merge keeps every member's pattern properties in one list, and a key passes if any merged pattern accepts its value. So A { [*]: string } & C { [/^n_/]: number } accepts { n_1: 'a' }: A's wildcard accepts the value, although C alone would reject it.
How a merge is built
The compiler writes the merged type as its final structure; nothing is merged at runtime.
- A property only one member has is that member's property — the same runtime object, so blocks on that member's type reach it.
- A property several members have is a property of the merge, whose annotations sit on top of the members' properties. On the same annotation the later member wins; repeatable
appendannotations (@expect.pattern…) collect every member's values, the earlier member's first. Blocks on the members' types still reach it, unless a later member sets that annotation. - A member written in place wins over the others: in
User & { @expect.maxLength 8 name: string }the in-placemaxLengthbeatsUser.name's own and those of later blocks onUser. - A named merge is a type of its own.
type AB = A & Bhas its own runtime class.annotate AB { … }annotatesABonly: notA, notB, and not the sameA & Bwritten somewhere else. - Merges flatten:
(A & B) & Cis the merge ofA,BandC. - A union operand is distributed:
(A | B) & CisA & C | B & C, and(A | B) & (C | D)is the productA & C | A & D | B & C | B & D. Branches that cannot merge (they would benever) are dropped. This also applies to a union of scalars and arrays:{ c?: string | X[] } & { c?: Y[] }givesc?: (X & Y)[], because thestringbranch cannot merge with an array. - A type picked from a union keeps its checks: when distributing leaves a single member of a union, the merged property is that member's type, with its built-in and annotated checks.
{ a: string } & { a: string.email | number }givesa: string.email, which still checks the email format. With@expect.pattern '^x' type X = string,{ a: string } & { a: X | number }givesa: Xwith its pattern. The same applies to each scalar branch of a distributed property. - A nullable operand at the top is the type it holds:
(X | null) & ZandXN & Zwithtype XN = X | nullareX & Z. When every operand allowsnull, the merge keeps it:(X | null) & (Z | null)isX & Z | null.
interface A {
@meta.label 'A.x'
@expect.pattern '^a'
x: string
onlyA: string
}
interface B {
@meta.label 'B.x'
@expect.pattern '^ab'
x: string
}
export type AB = A & B
// AB.x: label 'B.x' (the later member wins), patterns '^a' then '^ab'
// AB.onlyA: A.onlyA itselfMerge rules at a glance:
| Operands | Result |
|---|---|
The same type twice (X & X), or a type and one checking nothing more (S3 & string) | That type (the one carrying the checks) |
Primitives of different kinds (string & number), different literals ('x' & 'y') | never |
A literal and its primitive ('x' & string), an extension and its base (string.email & string) | Narrowed: the literal, the extension (with its built-in checks) |
Two sides that each check something ('x' & S3, 1 & number.int, string.email & string.uuid, decimal) | A runtime intersection checking both |
| Objects | One object with the props of all members; a prop several declare gets the intersection of them |
| Arrays; tuples of scalars | Element by element; item by item |
| A prop typed by a union of objects, a tuple of objects, an array of arrays (or of unions) of objects | never, with a warning (see below) |
| A union and anything | Distributed over the branches; branches that cannot match are dropped |
In detail:
- A property declared by several members gets the intersection of their types:
{ x: A[] } & { x: B[] }givesx: (A & B)[],{ y: A | null } & { y: B }givesy: A & B, and{ y: A | null } & { y: B | null }givesy: A & B | null. - A recursive merge is a recursive type: with
A.next?: AandB.next?: B,(A & B).nextisA & Bagain. - An intersection carries its members' type-level annotations (of the interfaces, types and
annotate … asaliases it intersects), below its own, the later member winning. An operand is a reference: type-level annotations whose spec declarespassedWhenReferred: false(@meta.readonly,@meta.required,@meta.id,@meta.default) are not carried. For example,@expect.minLength 2 type L1 = X[]makesL1 & X[]reject a single element. - Tuples of scalars intersect item by item:
[string, number] & [string, number]gives[string, number], and an array intersecting such a tuple gives a tuple of the items' intersections. Tuples of different lengths givenever. - Primitives of different types (
string & number) givenever. A literal narrows a primitive of its type ('x' & stringis'x',{ p: 'a' | 'b' } & { p: 'a' }givesp: 'a';'x' & 'y'isnever), and a primitive extension its base (string & string.emailisstring.email, in either order). A literal and an extension (1 & number.int), or two extensions (string.email & string.uuid), stay an intersection checking both. A side is dropped only when it checks nothing the other does not, so no constraint is lost: with@expect.minLength 3 type S3 = string,S3 & stringandstring & S3areS3, while'x' & S3,S3 & L4(another annotated alias) and'x' & decimalstay intersections checking both. - A type repeated (
X & Y & X) counts once, where it last occurs:X & Y & XisY & X. This holds however it is spelled, directly or inside named merges: withtype XX = X & Xandtype XY = XX & Y,X & XY & XXisY & X, so on the same annotationXwins. Parentheses around a single type (({ a: string })[]) change nothing. - An optional property passes
null(as it passesundefined): intersected with a required one that may benull, it may benulltoo ({ a?: X } & { a: null }givesa: null). Otherwisenullis a scalar like any other: an object or array merged with a barenull({ a: X } & { a: null },{ a?: X[] } & { a?: null }) resolves tonever, with a warning. When every member's type may benull({ a: X | null } & { a?: null }), the merge isnull.
Merges that resolve to never
A merge matches the members' props slot by slot: a prop's object, an array's element, the type a nullable union holds. Where two members declare a prop the merge cannot match slot by slot, the prop resolves to never (no value is valid there), and the compiler warns: Intersection of unions of objects cannot be merged at "M.u"; the type resolves to never. These shapes are:
- a union of several types holding objects in one member and an object in another (
{ p: X | Y } & { p: Z },A & Bwithu: X | Yandu: X | Z); - tuples holding objects (
{ p: [X] } & { p: [Z] }); - arrays of arrays, of tuples or of unions holding objects (
{ p: X[][] } & { p: Z[][] },{ p: (X | Y)[] } & { p: Z[] }). This applies even when both members write the same type inline ({ n: X[][] } & { n?: X[][] }), because only a named type counts as one type. Name it to merge it: withtype V = X[][],{ n: V } & { n?: V }givesn: V; - values with nothing in common (
{ p: X } & { p: string },string & number, an object or array and a barenullunless every member may benull), including a union of scalars and arrays whose branches all fail to merge ({ m?: number | number[] } & { m?: { c: number } }).
A prop optional in every member stays optional when it resolves to never: a value without it is valid.
A union at the top of an intersection ((X | Y) & Z, p: (X | Y) & Z) is not one of them: it is distributed over its branches, as above. The props of scalars ({ p: 'a' | 'b' } & { p: 'b' | 'c' }, { p: string[][] } & { p: string[][] }) merge as before.
Merges the compiler rejects
A few merge shapes are compile errors. The module is not generated until you fix them; each fix splits the shape with named types.
A type cannot be an operand of its own merge. Recurse through a field instead:
interface A {
a: string
}
export type X = A & X // Error: a type cannot be an operand of its own merge; recurse through a field insteadThe same goes for a cycle of named merges (type Y = Z & A with type Z = Y & A). Write the recursion as a property: interface X extends A { next?: X }.
A union member inside a recursive merge. When a merge on a recursion cycle merges a property several members declare and one of them is a union, the compiler reports union members are not supported in recursive merges; split with named types:
interface Card {
title?: string
next?: Card
}
interface Note {
text?: string
next?: Note
}
export interface Tagged {
tag?: string
next?: (Card | Note) & Tagged // Error: next is declared by Card / Note and by Tagged
}Split the members so that the recursive property is declared by one of them only:
interface CardFields {
title?: string
}
interface Card extends CardFields {
next?: Card
}
interface NoteFields {
text?: string
}
interface Note extends NoteFields {
next?: Note
}
export interface Tagged {
tag?: string
next?: (CardFields | NoteFields) & Tagged
}A union at the top of a merge whose branches recurse (t?: (N | D) & C), and ordinary merges nested inside a recursive merge, are fine.
Too many merges in one module. A module whose merges need more than 200 distinct merged types (merges of merges of recursive types can multiply) reports merge too complex: split it with named types. Name the intermediate merges (type AB = A & B) and use the names. The same error covers a merge whose union members distribute into too many branches (more than 16,384 combinations, such as 15 two-member unions intersected): name the parts.
Mutually recursive merges in a database table. Where the db layer needs a table's flat field list (@db.table, @db.view), a recursion that passes through two or more different merges is an error on the table: flattening this type reaches mutually recursive merges; split it with named types.
interface Extra {
note?: string
}
interface Post {
title: string
author?: Author & Extra
}
interface Author {
name: string
latest?: Post & Extra
}
@db.table 'feeds'
export interface Feed { // Error: flattening this type reaches mutually recursive merges
id: string
top?: Post
}Name the merges (type AuthorX = Author & Extra, type PostX = Post & Extra, and author?: AuthorX, latest?: PostX), or drop the merge from the cycle. A merge that recurses only into itself (next?: A & B inside A) is fine, and types that are only validated (not db tables) are not affected.
Practical Example
import { Address } from './address'
export interface User {
id: string.uuid
username: string.required
email: string.email
profile: {
displayName: string
bio?: string
[/^social_.*/]: string
}
addresses: Address[]
status: 'active' | 'inactive' | 'pending'
createdAt: string.isoDate
}TypeScript Usage
In TypeScript, use type aliases as both types and validators:
import { Username } from './types.as'
const name: Username = 'john_doe'
const validator = Username.validator()
if (validator.validate(input, true)) {
// input is Username
}Next Steps
- Imports & Exports — Module system
- Primitives — Semantic types
- Annotations — Metadata system
- Ad-hoc Annotations — Annotate existing types without modification