CoHDL
  1. Docs
  2. The language

The language

CoHDL treats a schematic as a typed program: values carry units, pins carry obligations, and the compiler refuses the design until every claim checks out. This page tours every construct in the language. The code is real — most samples are copied from the compiler's own component libraries and the Raspberry Pi Pico 2 example, and the error transcripts are genuine compiler output. The normative statement behind this tour is the language specification, with the full rationale in the RFCs.

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
VoltageV3.3V, 5V
CapacitanceF100nF, 10uF
Resistanceohm (ASCII only — never Ω)10kohm, 330ohm
CurrentA500mA, 2A
FrequencyHz16MHz, 32kHz
Times10ms, 1us
InductanceH10uH, 100nH
PowerW250mW, 1W
TemperatureC (ASCII only — never °C)85C, -40C
Tolerance%1%, 0.5%
Lengthmm0.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:

lib/usb/src/connectors/type_c.cohdl excerpt
#[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:

lib/std/src/prelude.cohdl excerpt
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 — satisfaction is explicit from the language specification
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:

examples/rpi-pico2/src/main.cohdl excerpt
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:

lib/passive/src/resistors_0402.cohdl generated
#[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:

lib/passive/src/circuits.cohdl excerpt
// 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:

examples/rpi-pico2/src/main.cohdl excerpt
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:

lib/passive/src/devices.cohdl excerpt
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:

generic fns compiler-verified
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:

lib/passive/src/devices.cohdl excerpt
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:

array-typed instances from the language specification
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:

module paths compiler-verified
// 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:

examples/rpi-pico2/src/main.cohdl excerpt
#[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.