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

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.