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_case—internal_service,pci_service,relational_db. (Modules areUpperCamelCase, interfaceslowerCamelCase; 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.
A custom module type
Section titled “A custom module type”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.
The shape of a type declaration
Section titled “The shape of a type declaration”type <stable_id?> <parent_type> <name> { <body> }<stable_id?>— optional#xyzslot. Formatter mints on save.<parent_type>— the type this one extends. Any registered type.module,surface,interfaceare the three base types; everything else is a user-defined subtype somewhere up the chain.<name>— the name your instances will use, inlower_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 custom surface type
Section titled “A custom surface type”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 } }}A custom interface type
Section titled “A custom interface type”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.
What a type body can contain
Section titled “What a type body can contain”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 body | Meaning |
|---|---|
field: value | Default value for instances |
required field | Mandatory blank — instance must fill |
cascade field: value | Default value that cascades to descendants |
append field | Field that composes with descendants’ values |
required cascade field | Mandatory blank that, once filled, cascades |
aspect { x: y } | Default aspect values |
required aspect x | Mandatory blank aspect |
rest_create X { ... } | Pre-filled interface — instance inherits |
required rest_create X | Mandatory blank interface — instance must refine |
component Y { ... } | Pre-filled sub-module |
required component Y | Mandatory 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.requiredmeans “no value”; the brace block means “here’s a value.” The validator rejects this. Either mark itrequired(blank, to be filled) or provide content (norequired).
Where types go
Section titled “Where types go”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 domainsFor 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.
What can’t be a parent type
Section titled “What can’t be a parent type”Two restrictions:
processandviewcan’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 { ... }becauseprocessis a keyword. Appendix B: Keywords lists the full set.
A worked metamodel slice
Section titled “A worked metamodel slice”Here’s a small set of related types defining a payments-domain vocabulary:
// 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.extras’actorandgroup) 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 touse, pick what they do to your thinking, not just the components they hand over.
Summary
Section titled “Summary”- A type extends a parent type by position:
type <parent> <name>. Noextendskeyword. - 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,interfaceare the three base types; everything else is a subtype.processandviewcan’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.
What’s next
Section titled “What’s next”Chapter 17: Required Blanks → — the mandatory-decision mechanism that makes types more than syntactic sugar.