Skip to content

16. Defining Types

You’ve been consuming types from the stdlib since Chapter 4. Now you write your own. This chapter introduces three: a custom module type, a custom surface type, and a custom interface type. Each one shows a different shape of type body.

Rule. Type names are lower_snake_caseinternal_service, pci_service, relational_db. (Modules are UpperCamelCase, interfaces lowerCamelCase; types get their own case so a reader can tell a type from an instance at a glance.)

Before you write any of these, recall Chapter 15: a custom type has to earn its keep. Each example below adds a real requirement, aspect, default, or widget — never a do-nothing wrapper. If your candidate type would add none of those, use a plain module instead.

Suppose your team has a notion of an internal service — a service that’s only callable from inside your VPC, must declare the version it’s running, and is tagged security.zone: Internal. Every instance is a service with those three things baked in.

The naive way: copy-paste the three lines into every internal service. The better way: define a type. This one earns its keep — it carries a required field and a default aspect, so it’s not a do-nothing wrapper.

type module internal_service {
required cascade version
aspect {
security.zone: "Internal"
}
}

Now an instance:

internal_service Inventory {
version: "1.4"
"Tracks stock counts in real time."
rest_create checkAvailability
rest_create reserve
}

Inventory gets a security.zone: "Internal" aspect automatically. It still has to fill in version because the type marked it required — that’s the next chapter.

Note that internal_service is declared with module as its parent type. That makes it a generic module subtype — you could also subtype service if you wanted the visual treatment of a service plus the constraints of internal_service:

type service internal_service {
required cascade version
aspect { security.zone: "Internal" }
}

Now internal_service instances render with the service widget by default (inherited from the stdlib service type), in addition to having the aspects and required version.

type <stable_id?> <parent_type> <name> { <body> }
  • <stable_id?> — optional #xyz slot. Formatter mints on save.
  • <parent_type> — the type this one extends. Any registered type. module, surface, interface are the three base types; everything else is a user-defined subtype somewhere up the chain.
  • <name> — the name your instances will use, in lower_snake_case.
  • <body> — defaults, blanks, sub-declarations, and modifiers.

There’s no extends keyword. The parent is encoded by position. type service payments_service { ... } means “payments_service extends service.”

A surface for “an HTTP resource”:

type surface resource {
"A surface representing an HTTP resource. Path composes via append."
append base
}

append base says: instances of resource carry a base field that composes with descendants’ values (a parent’s /orders and a child’s /items resolve to /orders/items). The append modifier is the subject of Chapter 18 — for now, take it as “the way path components stack.”

Used as:

service Orders {
resource OrdersResource {
base: "/orders"
rest_create post
rest_read get
resource Items {
base: "/items" // appends to /orders → /orders/items
rest_read list
}
}
}

For a webhook interface type that takes a default protocol: webhook field:

type interface webhook {
protocol: webhook
"An HTTP webhook callback delivered by an external system."
}

Used as:

service OrderWebhooks {
webhook orderCreated
webhook orderCancelled
}

Every webhook instance inherits protocol: webhook. The instance can override or drop it.

Every shape we’ve already met as a body member can appear in a type body, with the addition of required markers and the cascade / append modifiers on fields:

In a type bodyMeaning
field: valueDefault value for instances
required fieldMandatory blank — instance must fill
cascade field: valueDefault value that cascades to descendants
append fieldField that composes with descendants’ values
required cascade fieldMandatory blank that, once filled, cascades
aspect { x: y }Default aspect values
required aspect xMandatory blank aspect
rest_create X { ... }Pre-filled interface — instance inherits
required rest_create XMandatory blank interface — instance must refine
component Y { ... }Pre-filled sub-module
required component YMandatory blank sub-module

The combinations of required, cascade, and append are the language for designing your metamodel.

Mistake to avoid. required component logs { rest_create Send } is a contradiction. required means “no value”; the brace block means “here’s a value.” The validator rejects this. Either mark it required (blank, to be filled) or provide content (no required).

Types live in .arch files, and the rule is the same one that governs every file in a workspace: organize by domain, not by element type. Keep a type close to its instances — in the same domain file as the modules that use it, or in a domain-scoped file beside them.

Don’t sweep every type into a bare types.arch; a file named after a syntactic category (types.arch, processes.arch) is the anti-pattern. Name the file for the domain or the vocabulary it carries instead:

acme.shop/
├── package.archspace
├── payments.arch # payment modules + their payment types
├── orders.arch # order modules + their order types
└── business-primitives.arch # shared business types used across domains

For a large org with a shared vocabulary used everywhere, the escape hatch is a dedicated, isolated types package that exports only what it chooses — used from anywhere it’s needed. Keep types domainly close until that scale actually arrives.

To make a type visible to other packages, mark it export:

export type module internal_service {
required cascade version
aspect { security.zone: "Internal" }
}

Without export, the type is internal to its package. Importers use the use mechanism from Chapter 12.

Two restrictions:

  • process and view can’t be parent types. Their bodies aren’t stampable templates (they’re sequences and projections respectively), so subtyping them has no useful semantics yet. Reserved for future iterations.
  • Reserved keywords can’t be type names. You can’t type module process { ... } because process is a keyword. Appendix B: Keywords lists the full set.

Here’s a small set of related types defining a payments-domain vocabulary:

types.arch
// A service that operates in PCI scope.
export type service pci_service {
required cascade version
aspect {
security.zone: "PCI"
}
required ext.runbook_url
}
// A service that processes external card transactions.
export type pci_service card_processor {
required ext.processor_vendor
required ext.api_docs_url
"A card processor — talks to an external vendor and is in PCI scope."
}
// An interface for a callback delivered by an external system.
export type interface webhook {
protocol: webhook
"An HTTP webhook callback."
}

Then in instances:

pci_service Authorize {
version: "2.1"
ext.runbook_url: "https://wiki/auth-runbook"
rest_create authorize
}
card_processor StripeIntegration {
version: "3.0"
ext.runbook_url: "https://wiki/stripe-runbook"
ext.processor_vendor: "Stripe"
ext.api_docs_url: "https://stripe.com/docs"
rest_create charge
webhook paymentSucceeded
webhook paymentFailed
}

The metamodel encodes domain knowledge: every PCI service has a runbook URL; every card processor names its vendor and API docs. The validator enforces these requirements at parse time. The vocabulary (pci_service, card_processor, webhook) reads naturally in source.

A type set is a meta-model. A library of custom types is more than a convenience bundle. By imposing requirements and guidelines it becomes a meta-model — a discipline that drives how the team thinks. Two lenses on the same mechanism: the nice-to-have lens (handy widgets and models, like arch.extrasactor and group) and the meta-model lens (a notation that shapes decomposition, like C4 expressed as an ArchLang library). When you package these payments types up for others to use, pick what they do to your thinking, not just the components they hand over.

  • A type extends a parent type by position: type <parent> <name>. No extends keyword.
  • Type bodies contain defaults, mandatory blanks, pre-filled sub-declarations, and propagation modifiers.
  • Type names are lower_snake_case; a custom type must earn its keep (requirement, aspect, default, or widget) — never a do-nothing wrapper.
  • module, surface, interface are the three base types; everything else is a subtype.
  • process and view can’t be parent types (yet).
  • Export types with export type ... to make them visible to importing packages.
  • Keep types domainly close to their instances; a related set of types is a meta-model.

Chapter 17: Required Blanks → — the mandatory-decision mechanism that makes types more than syntactic sugar.