Macros Without Magic
How a familiar surface syntax and a small, regular core let user-defined abstractions feel like part of the language.
The previous two design pillars of Seamless are crash-safety and immutability. Both are about reducing cognitive load, removing hidden order of execution and spooky action at a distance, and thus maximizing the ability to reason locally.
The next design pillar is extensibility.
Language extensibility is about being able to fit the language and program structure to the problem domain, instead of fitting the domain into a rigid set of structures and language abstractions. As Paul Graham argued, regularity in a program that does not follow the shape of the problem domain is probably a sign of a missing abstraction.
That is what we usually call boilerplate.
But extensibility is not only about reducing repetition. It is about letting libraries introduce abstractions that fit the problem and feel like natural parts of the language.
Consider Seamless’s cond expression:
cond {
status == 200 => "ok"
status == 404 => "not found"
else => "unexpected response"
}It looks like a control-flow construct built into the language. In fact, cond is just a macro. At compile time, the example expands to:
if status == 200 {
"ok"
} else {
if status == 404 {
"not found"
} else {
"unexpected response"
}
}Back when I saw one of Rich Hickey’s presentations introducing Clojure and showing how or is just a macro, defined in ordinary library code rather than hard-coded as an operator, my mind was completely blown.
Due to the regularity of Lisp, any user-defined function or macro looks and feels like a language built-in, by design.
That is extensibility turned to 11.
Paul Graham joked that Lisp-style macros may require a language to look as strange as Lisp.
That’s because a large part of the power of Lisp macros comes not merely from the concept of compile-time syntax transformations, but from the simplicity of the representation they operate on.
Macros exist in other languages too, but often you have to parse a low-level token stream or work with a complex AST structure, and this quickly gets complicated. Because Lisp source forms are represented as nested lists and atoms, such as symbols and literals, they are easy to inspect, manipulate, transform, and produce. All you need are ordinary operations over lists, symbols, and literals.
That is what makes Lisp macros so powerful.
It is better to have 100 functions operate on one data structure than 10 functions on 10 data structures.
While Lisp syntax is beautiful in its simplicity and regularity, there is undeniably value in having more syntactic structure to aid readability.
Can We Have Our Cake and Eat It Too?
There have been attempts to wrap a nicer surface syntax around Lisp’s S‑expressions, with varying degrees of success. For a long time, I did not think it could be done in a way that was familiar and palatable enough.
Then Elixir’s syntax and regular quoted representation showed me that it could be done.
This:
if true do
1
else
2
endis equivalent to ordinary call syntax:
if(true, do: 1, else: 2)Its quoted form can be matched as regular tuple and list data:
{:if, metadata, [true, [do: 1, else: 2]]}The middle element is a keyword list containing source and compilation metadata.
Similarly, in Seamless:
if true {
1
} else {
2
}desugars to:
(if true 1 2)The goal is a familiar surface syntax over a small and regular core representation.
A Friendly Surface, a Regular Core
Seamless parses source code in two separate stages.
At the outer layer sits the surface parser. It reads the raw source and tokenizes it, but it does not produce a semantic AST directly. Instead, it lowers broad, reusable syntactic shapes into an S‑expression‑like representation.
The surface parser can recognize structures such as headed blocks, infix operators, and =>-separated entries without assigning them domain-specific meaning. It does not need to know that cond represents conditional control flow.
Surface syntax like this:
def classify(score) {
cond {
score >= 90 => Grade.A
score >= 80 => Grade.B
else => Grade.C
}
}gets turned into:
(def classify (score)
(cond
((>= score 90) Grade.A)
((>= score 80) Grade.B)
(else Grade.C)))At this stage, the structural data consists of nested lists and atoms: bare identifiers, called symbols, such as def, classify, score, cond, >=, and Grade.A, as well as literals such as 90 or "hello".
The public representation of this in Seamless code is correspondingly small:
module Syntax
@opaque
record {
datum: Datum
context: Context
}
@opaque
record Context {
token: Int32
}
union Datum {
Symbol(String)
Keyword(String)
Int(Int)
HexInt(String)
Double(Double)
Boolean(Boolean)
String(String)
Bytes(Array(Byte))
List(Kernel.List(Kernel.Syntax))
BracketList(Kernel.List(Kernel.Syntax))
}The regular structural data lives in Syntax.Datum.
The representation is not literally bare S‑expressions, because every Syntax node also carries an opaque Syntax.Context. That context records whether the node came from authored source or from an expansion, preserves nested expansion ancestry, and leaves room for hygienic scopes.
Fresh generated names are created explicitly. General hygienic scope handling is a separate concern that the context representation is designed to support.
This representation is deliberately less detailed than the compiler’s semantic AST. That is exactly why it makes a good model to parse, manipulate, and transform.
To produce Syntax, a macro can also use quote:
Shell.Main> quote { def classify(score) { cond { score >= 90 => 1 else => 2 } } }
(def, classify, (score), (cond, ((>=, score, 90), 1), (else, 2)))cond as a Macro
Once cond has been lowered to a regular list, its expansion is almost boring.
At the lowest level, a macro can work directly with Syntax using ordinary case expressions. Schematically, the implementation of cond is structured like this:
macro cond(arguments, _env) {
case arguments {
[] => Macro.error("cond requires at least one branch")
_ =>
case parse_branches(arguments) {
Ok(branches) =>
case expand_branches(branches) {
Ok(code) => Macro.replace(code)
Err(diagnostic) => Macro.fail(diagnostic)
}
Err(diagnostic) => Macro.fail(diagnostic)
}
}
}The parsing step, parse_branches, validates that each branch contains a condition and a body. The expansion step, expand_branches, recursively turns those branches into nested if expressions.
The sketch above uses low-level case analysis to make the expansion protocol explicit. This is not how macro authors are expected to implement a complex macro grammar. Seamless also provides a higher-level parsing abstraction over Syntax for describing and validating macro-specific grammar, with consistent source-aware diagnostics.
That abstraction is itself implemented on top of the same small syntax representation rather than requiring special compiler support.
For basic macros, ordinary case, quote, and unquote are already enough:
import Std.Host.IO (println)
macro unless(arguments, _env) {
case arguments {
[condition, body] =>
Macro.replace(quote {
if unquote(condition) {
{}
} else {
unquote(body)
}
})
_ => Macro.error("expected unless condition { body }")
}
}
def report(status) {
unless status == 200 {
println("request failed") |> Result.ignore()
}
}Expansion Does Not Bypass the Language
After cond expands, the result goes through the same semantic parser, name resolution, type inference, capability checks, and lowering as handwritten code.
Consider:
cond {
ready => 1
else => "not ready"
}The macro can successfully turn this into:
if ready {
1
} else {
"not ready"
}But the expanded program is still ill-typed. One branch returns an Int; the other returns a String.
The macro operates on Syntax. It can influence which program is generated, but it cannot bypass the normal type checker, so the compiler rejects the result.
The same rule applies to generated declarations, calls, capabilities, and region-scoped values. A macro can save the programmer from writing repetitive structure. It cannot smuggle invalid structure past the compiler.
Keeping expansion before typed AST elaboration gives us both halves of that property:
- macro authors work with a small, stable syntax representation
- expanded code remains subject to the full language
The compiler retains semantic authority without retaining exclusive authorship of every useful abstraction.
From cond to test
cond only rearranges expressions. A test declaration shows how much further the same idea can go.
Seamless’s test library lets a source file contain:
test "addition is associative" {
assert((1 + 2) + 3 == 1 + (2 + 3))
}test looks like a declaration the compiler would have to know about. In reality, it is a macro from the SmUnit.Suite package.
At the regular syntax boundary, the source is just a headed form with a name and a body:
(test
"addition is associative"
(assert (== (+ (+ 1 2) 3) (+ 1 (+ 2 3)))))The macro expands that form into an ordinary function declaration with a fresh generated name, a checked signature, and compile-time metadata.
Schematically, the result is:
@package
@SmUnit.Suite.test_case("addition is associative")
@type () -> SmUnit.Suite.Check(Unit)
def $fresh_test_name() {
assert((1 + 2) + 3 == 1 + (2 + 3))
}A package author can introduce a source form that:
- has its own validation and diagnostics
- creates declarations
- attaches metadata
- generates fresh names
- participates in normal imports and module visibility
- produces code checked by the same compiler as handwritten code
From the user’s point of view, test is simply part of the language available in that module.
From the compiler’s point of view, it is structured input that becomes ordinary declarations before semantic analysis.
The same pattern can eventually support routes, validators, commands, component declarations, client/server boundaries, and other framework abstractions without giving each one a private compiler feature.
Extensible Does Not Mean Grammar-Free
There is still a small language kernel.
The surface parser owns tokens, delimiters, precedence, source spans, and a few structural rules. if, function definitions, and other irreducible forms eventually need semantic implementations.
A form macro cannot invent arbitrary punctuation or reinterpret every token around it. It works within the broad structural shapes understood by the surface grammar.
The unless form above is possible because the parser can lower a headed block such as:
unless ready {
wait()
}to:
(unless ready (wait))without knowing whether unless is a built-in, an imported macro, or an unresolved name. Macro resolution happens after that structural lowering.
The same applies to the entries inside cond. The parser can recognize the reusable shape of an =>-separated entry without knowing that the enclosing macro will interpret it as a condition and body.
This is the useful balance.
The language has enough fixed grammar to remain coherent and toolable, while packages can define new form-level abstractions inside that grammar. Those abstractions use the same syntax representation, expansion protocol, diagnostics, name resolution, and checking path as bundled ones.
Without Magic
Macros are sometimes described as magic because they appear to change the language.
But the magic usually comes from an invisible boundary: text is rewritten behind the compiler’s back, generated code follows different rules, or a framework-specific build step secretly maintains a second language.
The goal in Seamless is the opposite.
The surface lowers to a small, regular core. Macros are ordinary compile-time functions over that core. Their output goes through ordinary checking. The fixed semantic language stays small, while the developer experience can grow.
cond can look built in without being fundamental.
test can look like a declaration without being known to the semantic parser or backend.
And if a useful abstraction is missing, a package should be able to add it without waiting for the compiler to bless a new AST node.