Blog ·
A PCB language with typed nets: connections that don't compile
Sergey Alekseev
A board in PCBKit is a text file, design.pcbkit, written in the PCBKit language: nets, parts,
blocks and connections with real units and no coordinates. Since edition 2 of the language the
compiler also types the electrical side of that file. Every net has an electrical type and every
pin a contract. A connection the stated facts forbid is an error at the line that wrote it,
before anything is placed or routed. Below is what that looks like in real code, what the errors
say and what it caught on the designs agents actually write.
What a typed pin looks like in the PCBKit language
A part states what each of its pins drives and accepts. This is the humidity sensor of the example on our platform page, an I2C part in SOIC-8:
part Sensor: Part {
footprint = soic(8)
pinout = {
VDD = "8"
GND = "4"
SDA = "5"
SCL = "6"
ADDR = "1"
NC1 = "2"
NC2 = "3"
INT = "7"
}
pinTypes = {
VDD = io.powerIn(min = 1.7V, max = 3.6V)
GND = .ground
SDA = io.openDrain(max = 3.6V)
SCL = io.input(max = 3.6V)
ADDR = io.input(max = 3.6V)
NC1 = .nc
NC2 = .nc
INT = .openDrain
}
}
The net’s type is inferred from what is on it: a ground, a rail with a voltage range and a
current, a signal with its drivers and pull-ups, half of a differential pair, or unjudged
when a fact the judgement needs is not stated. Nothing reads a name. A net called +3V3 is not
3.3 V to the type checker until something on it says so (ADR 0104).
Stated voltage ranges: 24 V is one point
The first rule we wrote down was that a nominal is not a range. voltage = 24V on a net that
enters the board is one point. If the supply can be 10 % high, the design says so and the
checker judges both ends of the range. This design is one of the compiler’s tests
(crates/pcbkit-core/tests/types.rs):
pcbkit 2
net VIN {
voltage = 24V ± 10%
current = 100mA
}
net GND
J1 = screwTerminal(2) { pins = [VIN, GND] }
J2 = header(2) {
pinTypes { "1" = io.powerIn(max = 25V) }
"1" -- VIN
"2" -- GND
}
J3 = header(2) {
pinTypes { "1" = io.powerIn(min = 22V, max = 30V) }
"1" -- VIN
"2" -- GND
}
C1 = cap(10uF, package = "1210", ratedVoltage = 25V) {
a -- VIN
b -- GND
}
It gives three errors. Line 10, type-limit: J2’s power input tolerates at most 25 V and the
stated range of VIN (21.6 V to 26.4 V) reaches 26.4 V. Line 15, type-operating: J3 needs
at least 22 V and the range goes down to 21.6 V. And type-voltage on C1, a 25 V capacitor on a
rail that reaches 26.4 V. Change 24V ± 10% to 24V and all three go away, because the design
now says the rail is exactly 24 V.
We did not make a range compulsory. A judgement that needs a range nobody stated is reported as
unjudged with the line to write, never passed and never failed. Requiring a range on every
incoming net would have made an error of every registry board with voltage = 5V on a header,
so the quickstart teaches ranges instead.
The type errors the compiler gives
The judgements are one module of the core, pcbkit_core::types. Each replaced an older
electrical rule of the verifier and the old one was deleted with its tests, so no fact is judged
by two pieces of code.
| Code | What cannot be written |
|---|---|
type-drivers |
two push-pull outputs or power outputs of different parts on one net |
type-sources |
sources at different voltages on one net (a 3.3 V and a 5 V output) |
type-limit |
a net’s highest level above a pin’s absolute maximum |
type-operating |
a power input below the least supply it runs from |
type-ground |
a ground pin on a supply, a supply pin on ground |
type-pullup |
an open-drain net (I2C, an interrupt line) with no pull-up |
type-current |
a net’s stated current above what a fuse, diode, inductor, contact or switch on it carries |
type-voltage |
the voltage on a capacitor, switch or TVS above its rating |
type-clamp |
a TVS clamping above the maximum of the pin it guards (a warning) |
type-level |
an output whose high level is below an input’s threshold (a warning) |
type-nc |
a pin not connected inside its part wired to a net |
Each message names both ends and the repair. The pull-up one, from the source: “open-drain net
SDA has no pull-up: … only pull it low; add a resistor from SDA to the supply (the block
I2cPullups, or res(4.7kohm))”. The full list with examples is in the
language reference.
Where a type error points
An electrical rules check usually reports at the component, after a build. The type checker
reports at the connection. Lowering records where each pad’s connection is written. A
finding points at the connection written last among its pins: the one that made the conflict.
In the test with two 3.3 V supplies on V3, the error is at line 13, where J2["1"] -- V3
joins the second one. When the pin is inside a block (two Ldo blocks driving the same rail) it
points at the vout -- V3 of the second block’s use, line 12, column 3.
design.write and design.edit answer these errors as soon as the design evaluates, so an agent
reads the error in the reply to the write that made the mistake, a build earlier than before. A
board with a type error is never complete.
A finding counts as a proof only when every fact it rests on is stated: the pins’ types are the design’s or its part sheet’s, every part is written in the design, every net voltage it read is declared or comes from typed pins. On an imported KiCad board the same judgement runs on facts assumed from names and KiCad pin types, so it keeps the same code and waits on probation like the rest of the verifier.
What it catches on agents’ designs
The agent corpus has 130 accepted defects on 17 boards. We sorted them by what a type checker could judge (dev2, main c695aa648, 2026-10-31):
| Class of defect | Accepted | Found by the checks | From stated facts only |
|---|---|---|---|
| classic ERC: two drivers, 5 V into a 3.3 V input, no pull-up, a pin on ground | 0 | ||
| a stress against a rating at a connection | 16 | 12 | 5 |
| everything else (layout, fab, parts, firmware, accuracy) | 114 | 63 | 0 |
Agents build from standard blocks whose parts are already matched, so they rarely wire 5 V into a 3.3 V pin. What they get wrong is stress against a rating: a 7 A fuse carrying 6.5 A (93 % of its rating), a 2.5 A jack at 2.5 A, a 3.32 V rail on a 3.3 V OLED. The verifier already found 12 of those 16. What the type checker adds on this corpus is proof. Before it, those findings were all unconfirmed: the agents read them as “check against the datasheet” and shipped. Nine more need facts we don’t state yet (a TVS’s real clamping voltage, a connector’s contact rating) and two need the verifier’s solves (interrupting capacity, inrush).
In 96 edition 2 dev conversations the code agents met most was the current rating, in 19 of them, mostly a PTC fuse held at 100 to 105 % of its rail’s current. On the 44 boards of the registry every code the type checker took over gives 0 findings, so speaking these as errors adds no false error there.
Intent as a type: connector mates and circuit targets
Of 140 real defects people fixed in later revisions of their own KiCad boards, 25 cannot be found from the board at all. Nine are wrong only against what plugs into it and 16 only against what the designer meant (ADR 0111; the faults are in our review benchmark). A design written in the language can state that intent. The type checker then reads it as one more set of typed pins:
J2 = header(6) {
mate = mates.ftdi3v3
"1" -- GND
"3" -- V5
"4" -- TX
"5" -- RX
}
The cable’s TXD meets the board’s own TX here, so it is type-drivers at the "4" -- TX line (“the FTDI
TTL-232R-3V3 cable’s pin at J2.4 …”). RX is a net with no driver. Swap the two lines and there
is nothing to say. A servo plugged into a header wired the other way is type-ground at its
line. A pogo fixture at 1.27 mm on a 2.54 mm header is type-mate.
A circuit’s values can state their target too:
require rcNetwork(R1.value, C1.value).cutoff in 1kHz ± 10% "the joystick's anti-alias filter"
fails at the write with “the joystick’s anti-alias filter: it is 159.2Hz, not in 900Hz to 1.1kHz”.
A decoupling capacitor is one line, C1 = cap(100nF) { decouples = U2.VDD }. Wiring it to the
wrong rail is type-decouples. The mate and the target forms reach 14 of the 25 cases.
Values the compiler leaves to PCBKit
In 84 edition 2 dev conversations, agents changed 143 stated values after their first build. Of
those, 93 (65 %) were values the tool can derive: fuse holds, capacitor ratings, board sizes, copper
rules. In 11 of the 25 first writes that set a fuse, the agent wrote fuseHold = 500mA on a
500 mA rail and the build then said it should be 750 mA. Now a design writes ? where a value
follows from the rest (fuseHold = ?, cap(10uF, ratedVoltage = ?)) and the code that owns the
physics fills it (ADR 0103). A value only the design can give, such as header(?), is error E0801:
“PCBKit does not choose” it.
A mistake in one line does not stop the board
On 22 first writes of the edition 2 dev runs, 12 evaluated as written. The compiler repairs
mistakes that have one meaning (5pct to 5%, voltage to ratedVoltage, a split
net A, B { … }) and the cases that remain become holes: the failed statement is left out, the
rest of the board is placed and routed and the answer leads with the hole and its fix (ADR 0098).
With both, 22 of 22 first writes gave a board and 17 were complete.
What is not done yet
- Block ports.
help.describeshows what each port of a block accepts (“vinaccepts at most 6 V”); judging an instance against a contract a block states for itself is designed but not built. - Facts the sheets don’t state yet. TVS clamping voltages and connector contact ratings come from a series’ typical ratio or a family’s typical rating, so those findings stay on probation.
- Imported boards keep the verifier’s judgement on assumed facts until they are written in the language.
- The surface of the language. We measured a flatter syntax on whole files: 57 to 37 lines on a 555 blinker, 42 to 42 on a USB to UART board, 1,012 to 878 bytes on an STM32 half bridge. Most of the gain is reachable in edition 2 already, so it stays a proposal for now.
The language reference is at /docs/language and the files of a project at /docs/format. How those files replaced KiCad’s as the project’s own storage is in the post about project files.