Units are types
CoHDL has a closed set of eleven primitive unit types. Each is a distinct type, and there is zero implicit coercion — not between unit types, and not from a bare number. A bare number where a unit-typed value is expected is a compile error naming the expected and actual types, and arithmetic between different unit types does not parse.
| Unit type | Written as | Example literals |
|---|---|---|
| Voltage | V | 3.3V, 5V |
| Capacitance | F | 100nF, 10uF |
| Resistance | ohm (ASCII only — never Ω) | 10kohm, 330ohm |
| Current | A | 500mA, 2A |
| Frequency | Hz | 16MHz, 32kHz |
| Time | s | 10ms, 1us |
| Inductance | H | 10uH, 100nH |
| Power | W | 250mW, 1W |
| Temperature | C (ASCII only — never °C) | 85C, -40C |
| Tolerance | % | 1%, 0.5% |
| Length | mm | 0.3mm, -23.64mm |
A literal is a number immediately followed by its unit symbol, no space
(100nF, never 100 nF). The grammar fixes which SI
prefixes each unit accepts; Temperature and
Tolerance take no prefix at all, and only
Temperature and Length literals may carry a
leading minus. Length is the geometry unit — pad sizes,
placements and silkscreen coordinates are all millimetre-typed values, not
bare numbers.
Because engineers reach for Ω and °C by habit, the
compiler catches them with a targeted diagnostic rather than a generic lex
error:
error[E101]: use `ohm`, not `Ω` — CoHDL resistance literals are ASCII-only
--> src/main.cohdl:226:39
|
226 | inst r_bad: passive::ChipResistor<10kΩ, 1%>[R0402]
| ^^^^
= help: write `10kohm`
Devices and pins
A device is pins plus specs — self-contained, with no trait
clause. Every pin carries two facts fixed at the declaration: a connection
obligation (required or optional) and an
electrical role from a closed six-value set. A single logical pin may span
several physical pin numbers (a pin bus). This is the USB-C receptacle from
the usb library:
#[doc("docs/hro-type-c-31-m-12-family-drawing.pdf")]
pub device USB_C_Receptacle_2_0 {
pins {
required GND: A1, A12, B1, B12 [power_in]
required VBUS: A4, A9, B4, B9 [power_out] // the upstream source drives VBUS
required CC1: A5 [passive]
required CC2: B5 [passive]
required DP: A6, B6 [bidirectional]
required DN: A7, B7 [bidirectional]
optional SBU1: A8 [passive]
optional SBU2: B8 [passive]
required SHIELD: SH1, SH2, SH3, SH4 [passive]
}
}
A required pin must be resolved in exactly one of two ways:
it appears in some net, or it appears in an nc
declaration — an explicit statement that the pin is deliberately not
connected. Appearing in neither is a compile error; appearing in both is a
contradiction and also an error. An optional pin may simply go
unmentioned. nc is never a second connectivity mechanism — a
pin listed under it joins no net — and because it is a declaration, a
reason can ride along as a checked-position
#[intent("...")] attribute instead of a stray comment.
The exhaustiveness check runs once, at final design assembly,
after all sub-circuit functions have been expanded — so a reusable fragment
may intentionally leave pins for its caller to resolve. Here is what
forgetting the shield connection actually looks like:
error[E701]: required pin `Pico2::usb.SHIELD` is unresolved: add it to a `net` or explicitly mark it `nc`
--> src/main.cohdl:18:5
|
18 | inst usb: usb::connectors::type_c::USB_C_HRO_TYPE_C_31_M_12
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
:: usb/connectors/type_c.cohdl:21:9
|
21 | required SHIELD: SH1, SH2, SH3, SH4 [passive]
| --------------------------------------------- `SHIELD` is declared `required` on device `USB_C_Receptacle_2_0` here
This replaces the blanket "unconnected pin" warning older tools emit — the kind that fires on every intentionally-unused pin until everyone learns to ignore it. Obligations make the distinction the author already had in mind checkable.
Traits and impl
Traits declare data-shape requirements — abstract pin roles and unit-typed
spec fields — and nothing else; they have no methods. The standard library
is deliberately only seven core traits: TwoTerminal,
Capacitor, Resistor, Polarized,
Diode, IC and Connector:
pub trait TwoTerminal {
pins {
required A: pin
required B: pin
}
}
pub trait Capacitor: TwoTerminal {
designator_prefix: "C"
spec {
capacitance: Capacitance
voltage_rating: Voltage
tolerance: Tolerance
}
}
// A polarized component: Anode must sit at the higher potential.
pub trait Polarized {
pins {
required Anode: pin
required Cathode: pin
}
}
A device never lists the traits it satisfies. Instead,
impl Trait for Device is its own free-standing statement,
checked exhaustively the moment it is written: every pin role and spec
field the trait requires (including sub-trait bounds, transitively) must
resolve against the device's own declarations. When the names already
match, the body is empty; when they differ, the body is exactly the
mapping:
impl TwoTerminal for MLCC {} // MLCC already has pins A/B — names match, body empty
impl Capacitor for MLCC {}
impl TwoTerminal for TantalumCap {
pins { A: Anode, B: Cathode } // names differ — explicit mapping
}
CoHDL never uses structural typing: a device whose shape happens to match a
trait does not satisfy it until an impl says so. Traits also
carry the designator mapping — designator_prefix: "C" above is
why every capacitor becomes C1, C2, … in the
outputs. The allocator itself guarantees that no two live instances ever
share a designator (checked as a postcondition on every compile) and that
assignments stay stable across rebuilds via design.lock.
Designs, instances and nets
A design block is where instances exist and wiring happens.
inst declares an instance of a device or part;
net connects pins by naming them. This is the Raspberry Pi
Pico 2 example, trimmed:
design Pico2 {
inst mcu: raspberrypi_mcu::RP2350A_QFN60
inst usb: usb::connectors::type_c::USB_C_HRO_TYPE_C_31_M_12
inst rcc1: passive::R_5K1_F_0402
inst rcc2: passive::R_5K1_F_0402
net VBUS [5V]: usb.VBUS, d_vbus.Anode, hdr.VBUS
// USB-C CC pulldowns (5.1k Rd — advertise a UFP/device sink)
net CC1: usb.CC1, rcc1.A
net CC2: usb.CC2, rcc2.A
nc: usb.SBU1, usb.SBU2
net GND [gnd]: mcu.GND, usb.GND, usb.SHIELD, rcc1.B, rcc2.B
}
A net member is an instance-pin reference, inst.PIN. A net may
carry at most one annotation in brackets after its name: a
Voltage literal stating the nominal rail voltage, or the
marker gnd. The residual design-rule checks — voltage-exceed
and polarity-mismatch among them — read only these annotations, never net
names, so net GND_MAYBE means nothing to the compiler until
you say [gnd].
The same pin appearing in two net declarations merges them
into one electrical net; conflicting annotations on a merged net (two
different voltages, or a voltage plus gnd) are a compile
error. net _: declares an anonymous net — the normal form
inside sub-circuit functions, where its name comes from the call chain. A
net that resolves to zero instance pins after expansion is a structural
error, not a warning. And once the whole design is assembled, the pin
exhaustiveness check from the previous section runs over every instance —
that is what "final assembly" means here.
Parts you can buy
A part binds a fully-concrete device instantiation — every
generic argument a literal — to an approved vendor list. Exactly one
primary, which must carry mpn and
footprint, and zero or more alt entries, each
with an mpn. The passive library's parts files
are generated from vendor datasheets, and every emitted primary part
number is verified against Yageo's own specsheet endpoint — a part that
does not resolve is omitted, never asserted:
#[doc("docs/yageo_rc_series_datasheet_en.pdf")]
pub part R_5K1_F_0402: ChipResistor<5.1kohm, 1%>[R0402] {
primary { mfr: "Yageo", mpn: "RC0402FR-075K1L", footprint: CHIP_0402 }
alt { mfr: "Vishay", mpn: "CRCW04025K10FKED" }
}
footprint: is a symbol reference to a
footprint declaration, resolved through the module system like
any other name — never a string naming some other tool's library. That
keeps footprints inside the type system: a part cannot point at a footprint
that does not exist, and at build time the footprint's pad numbers are
checked against the device's declared pins.
An instance binds to a part either by name (inst rcc1:
passive::R_5K1_F_0402) or by exact spec match — instantiate the
device with concrete values, and if a part matches them exactly, the
instance binds to it deterministically. cohdl build requires
every instance to be part-bound before it will emit a netlist and BOM,
which is what makes a BOM that lies about the design structurally
impossible; cohdl check does not, so exploration stays cheap.
Sub-circuit functions
A fn is a reusable circuit fragment: calling it instantiates
its body and wires it to the arguments. The canonical example is the
decoupling capacitor, from the passive library:
// One 100nF decoupling capacitor across a supply pin pair.
pub fn decoupling_100n(vdd: Pin, gnd: Pin) {
// RFC-028: one attribute here annotates every call site's capacitor.
#[bypass(vdd, 100nF)]
inst c: C_100n_16V_X7R_0402
net _: vdd, c.A
net _: gnd, c.B
}
Each call site produces its own real capacitor with its own designator — the Pico 2 design calls it ten times across the MCU and flash supply pins:
passive::decoupling_100n(mcu.IOVDD, mcu.GND)
passive::decoupling_100n(mcu.QSPI_IOVDD, mcu.GND)
passive::decoupling_100n(flash.VCC, flash.GND)
Calls nest to arbitrary depth, and a nested call expands exactly as if it
had been written at the call site. Every produced instance and net is named
from its full call-chain path, so two calls to the same
fn can never collide (the compiler reserves the
__ name prefix to make that guarantee unforgeable). What can
never happen is a cycle — expansion that would re-enter an active
fn is rejected with the full chain spelled out. Here two fns
from the next section were wired into a deliberate cycle:
error[E501]: recursive fn call: `supply_entry` is already being expanded in this call chain: supply_entry → bypass_cap → supply_entry
--> src/tour.cohdl:10:5
|
10 | supply_entry::<V>(vdd, gnd)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
Generics over specs
A generic parameter on a device or fn is one of exactly two
kinds: a unit-type parameter (V: Voltage, optionally with a
visible default) or a trait-bound type parameter (D:
Capacitor, bounds joined with +). The
passive library's MLCC is a generic device with two defaults:
pub device MLCC<C: Capacitance, V: Voltage = 16V, T: Tolerance = 10%> {
variants { C0201, C0402, C0603, C0805, C1206, C1210 }
pins[C0402] {
required A: 1 [passive]
required B: 2 [passive]
}
// ... one pins[VARIANT] block per declared variant ...
spec {
capacitance: C
voltage_rating: V
tolerance: T
}
}
Generic fns take the same two parameter kinds. Calls use
turbofish syntax with positional arguments, and substitutions thread
outward-in through nested calls — this pair type-checks against the real
compiler:
use passive::MLCC;
pub fn bypass_cap<V: Voltage>(vdd: Pin, gnd: Pin) {
inst c: MLCC<100nF, V>[C0402]
net _: vdd, c.A
net _: gnd, c.B
}
pub fn supply_entry<V: Voltage>(vdd: Pin, gnd: Pin) {
bypass_cap::<V>(vdd, gnd) // nested call — V resolves at the outer call site
}
// Trait-bound parameter: any device with `impl Resistor` in scope.
pub fn tie<D: Resistor>(target: D, a: Pin, b: Pin) {
net _: a, target.A
net _: b, target.B
}
Zero coercion applies to substitution with no exception — pass a capacitance where a voltage parameter is expected and the error names both sides:
error[E112]: generic argument for `V` has the wrong unit type: expected `Voltage`, found `Capacitance`
--> src/main.cohdl:223:20
|
223 | supply_entry::<100nF>(mcu.IOVDD, mcu.GND)
| ^^^^^ `100nF` is a `Capacitance`
Trait bounds are checked at the instantiation or call site: the compiler
looks for a satisfying free-standing impl in scope for each
bound, and a missing one is an error naming the trait and the concrete
type. An impl Trait-typed value parameter is sugar for an
anonymous trait-bound parameter — one mechanism, not two.
Pattern matching
CoHDL's pattern matching is structural: two closed sets, both exhaustively
covered, with no implicit defaults. The first is pin roles — every device
pin carries one of input, output,
bidirectional, passive, power_in,
power_out. An unannotated pin is a compile error listing all
six; the driver roles (output, power_out) are
what the single-driver and multi-driver design-rule checks consume.
The second is package variants. A device may declare a closed set of
structural shapes, and every declared variant must have its own
pins[VARIANT] block — that is the exhaustiveness check, at
the declaration itself. Variant-specific spec overrides are how one
declaration states that an 0402 resistor is rated 62.5 mW while a
1206 is rated 250 mW:
pub device ChipResistor<R: Resistance, T: Tolerance = 1%> {
variants { R0201, R0402, R0603, R0805, R1206, R1210, R2010, R2512 }
pins[R0402] {
required A: 1 [passive]
required B: 2 [passive]
}
// ... a pins block for each of the eight variants ...
spec {
resistance: R
tolerance: T
}
spec[R0402] {
power_rating: 62.5mW
}
spec[R1206] {
power_rating: 250mW
}
}
Instantiating a device with variants requires selecting one — there is no default footprint, because "which package is this" is not a detail to guess:
error[E904]: device `passive::ChipResistor` declares variants — select one with a `[VARIANT]` suffix (no implicit default)
--> src/main.cohdl:226:19
|
226 | inst r_novar: passive::ChipResistor<10kohm, 1%>
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
= help: valid variants are: R0201, R0402, R0603, R0805, R1206, R1210, R2010, R2512
Instance arrays
inst NAME: [Device; N] declares one array-typed instance whose
N elements are each fully real — each gets its own designator, its own pin
obligations, its own trait satisfaction. Indexing is 0-based and literal
only, and a bare unindexed NAME is never a valid reference.
This is the construct behind a keyboard's thirteen switches being one line
instead of thirteen:
inst key_leds: [RGB_SK6812; 13]
// each element is individually addressable, everywhere a reference is valid
net LED_D0: mcu.LED_DATA_KEY, key_leds[0].DIN
net LED_D1: key_leds[0].DOUT, key_leds[1].DIN
// range and list fan-out — sugar, valid inside a net's member list only
net VBUS [5V]: usbc.VBUS, key_leds[0..=12].VDD
net COL0: mcu.COL0, d[0, 4, 8, 12].Cathode
NAME[i] works everywhere an ordinary instance reference does —
as a net member, as the target of a place statement, and in
fn-call arguments (decouple(key_leds[0].VDD,
key_leds[0].GND)). An out-of-bounds index is a compile error naming
the valid range. The fan-out forms NAME[a..=b].PIN and
NAME[i, j, k].PIN expand to individual references and are
deliberately scoped to net member lists — a whole range has no single
sensible meaning as a placement target. There is no loop construct: the
daisy-chain wiring above is written one link at a time, addressable but
not auto-generated.
Modules and visibility
A package's module tree mirrors its file tree under src/,
rooted at the manifest's package name — there is no mod
declaration. The file
lib/usb/src/connectors/type_c.cohdl is the module
usb::connectors::type_c, and its public names are reachable
fully qualified or imported once:
// Fully qualified, always valid:
inst usb: usb::connectors::type_c::USB_C_HRO_TYPE_C_31_M_12
// Or import once, use unqualified thereafter:
use usb::connectors::type_c::USB_C_HRO_TYPE_C_31_M_12;
inst usb: USB_C_HRO_TYPE_C_31_M_12
use path::Name; imports exactly one name — there are no glob
imports. Within a single package, every name in every file stays visible
unqualified; across packages, nothing is implicitly visible, and
pub is enforced at exactly that boundary — referencing a
non-pub item from another package is a compile error naming
the item and its actual visibility. Name collisions are scoped per module
path, so two libraries can each declare a TPS62840 without
conflict. A scoped registry package like
"@raspberrypi/mcu" in the manifest becomes the
raspberrypi_mcu:: namespace in source.
Annotations
Two attributes carry metadata with a guaranteed zero impact on
compilation — mutating #[intent("...")] or
#[doc("...")] can never change a verdict, a designator, or an
emitted byte. #[intent("...")] attaches one
opaque rationale string to a declaration (inst,
net, nc, impl, device,
trait, fn or part; at most one per
declaration). It is the structured home for the sentence a reviewer will
otherwise ask for:
#[intent("USB-C VBUS is 5V (5.1k Rd pulldowns advertise a UFP sink); the Schottky reverse-blocks so an external VSYS supply cannot backfeed the host")]
#[high_current(500mA)]
net VBUS [5V]: usb.VBUS, esd.VBUS, d_vbus.Anode, r_vbus_top.A, hdr.VBUS
#[designator("U7")] shares the attribute syntax but is the
deliberate exception to that guarantee: on an inst it
overrides the automatically-assigned designator — changing the outputs is
its whole purpose, though it never affects connectivity. Overrides are
resolved before any fresh assignment, so one can never silently clobber
the other.
#[doc("relative/path.pdf")] attaches reference documents —
datasheets, application notes — to a declaration; unlike
#[intent] it may repeat, the path is relative to the package
root, and the compiler never opens the file.
A separate family of structured attributes carries physics facts for the
layout tools — #[ground], #[high_current],
#[impedance], #[bypass],
#[crystal_oscillator], #[switching_converter],
#[bga_fanout] and #[placement_hint], alongside
the layout {} block. Those have real, checked argument
grammars rather than opaque strings, and they are covered with the rest of
the physical-design surface in
Layout & fabrication.
Canonical form
cohdl fmt defines exactly one canonical rendering for every
construct on this page — no configuration, no style options. It is a pure
function of the parsed AST (parse, then re-serialize — never text
munging), which makes it idempotent and semantically inert by
construction: formatting can never change a parse tree, a verdict, or an
emitted netlist byte. Trailing comments and author-placed blank-line
groupings are preserved; indentation, spacing and wrapping are not up for
debate.
$ cohdl fmt . --check
All files are in canonical form.
The --check variant mutates nothing and fails when a file is
not canonical, which makes it the natural review gate: run it in CI and
every diff a reviewer — human or model — reads is a semantic diff, never a
whitespace one.