DEV Community

Mat Weiss
Mat Weiss

Posted on

"It Didn't Happen" vs. "It Couldn't Happen"

Ever notice this pattern: a core type exists — User, Order, ShipSystems — and then derived types fan out from it? Response DTOs, dashboard views, API contracts, audit records. Each is a subset of the source, maintained separately, with no compiler-visible relationship to the original.

I started designing projections because I was tired of writing DTOs. What I ended up with was a correctness feature I hadn't anticipated — and one that generation alone can't provide.

The Subset Problem

Consider a starship's operational systems. Engineering, Tactical, Medical, and Science all need access to ship status, but each department should see only what it needs. Two approaches are standard.

The first: write separate types for each view. EngineeringData with five fields, TacticalData with four, MedicalData with two. Each is a manual subset of ShipSystems, maintained independently. When ShipSystems gains a field, each subset type is unaffected — which is either correct or a silent omission, depending on whether the new field belonged in that view. GraphQL takes a different angle on the same problem — let the client declare which fields it wants — but the boundary usually lives in schema and resolver tooling, not as a compiler-tracked derivation from the source type.

The second: write a filter function that strips fields at runtime.

function getShipData(role: DepartmentRole): ShipData {
  const systems = repository.getCurrentStatus();
  switch (role) {
    case "engineering":
      return filterToEngineeringFields(systems);
    case "tactical":
      return filterToTacticalFields(systems);
    case "medical":
      return filterToMedicalFields(systems);
    case "science":
      return filterToScienceFields(systems);
  }
}
Enter fullscreen mode Exit fullscreen mode

Both work. The manual types are safe but tedious — and they drift from the source type over time, with nothing connecting the two. The filter function is flexible but operates at runtime, where misconfiguration can be silent: a filter that returns weapons targeting data to Medical may produce no error, no warning, no signal that anything went wrong.

Some type systems get partway there. TypeScript's Pick<ShipSystems, 'hullIntegrity' | 'warpCorePower'> gives you a structural subset — an operation typed against it cannot access fields outside it. That's real, and it's better than a runtime filter. But Pick is passive: add a field to ShipSystems and every Pick that excludes it continues to compile silently. No decision was forced.

Every developer who has written a DTO has said it in their head: "ShipSystems, but only these five fields." The missing piece is derivation tracking — a compiler that knows which views were derived from which source types, and surfaces the effect of schema changes across all of them.

Projections

This is what I wanted — a way to say "this type, but only these fields" that the compiler tracks. Ruuk calls them projections: typed views of a record type that maintain a declared relationship to their source.

Start with the full ship systems record:

pub type ShipSystems = {
    hullIntegrity: Float
    shieldStrength: Float
    weaponsStatus: WeaponsStatus
    targetingData: TargetingData
    warpCorePower: Float
    auxiliaryPower: Float
    lifeSupportLevel: Float
    crewBiosigns: List<Biosign>
    sensorReadings: SensorData
    missionParameters: MissionBriefing
    navigationCourse: CourseData
    communicationsLog: List<CommEntry>
}
Enter fullscreen mode Exit fullscreen mode

Now declare what each department can see:

type EngineeringView = ShipSystems only {
    hullIntegrity; warpCorePower; auxiliaryPower; lifeSupportLevel; shieldStrength
}

type TacticalView = ShipSystems only {
    shieldStrength; weaponsStatus; targetingData; sensorReadings
}

type MedicalView = ShipSystems only {
    crewBiosigns; lifeSupportLevel
}

type ScienceView = ShipSystems only {
    sensorReadings; navigationCourse
}
Enter fullscreen mode Exit fullscreen mode

Each projection is a type. EngineeringView has five fields. targetingData is not one of them — not because a filter removes it at runtime, but because it was never part of EngineeringView. The type doesn't have the field. There's nothing to misconfigure.

The only operator names the fields to include; everything else is excluded. Ruuk also has without, which works the other direction — name the fields to exclude, include everything else:

type PublicShipStatus = ShipSystems without {
    targetingData; missionParameters; communicationsLog
}
Enter fullscreen mode Exit fullscreen mode

The choice between only and without is about intent and maintenance. only is explicit: you get exactly these fields, nothing more. When ShipSystems gains a new field, an only projection doesn't include it — you've declared what you want. without is inclusive by default: you get everything except the named fields. When ShipSystems gains a field, a without projection includes it automatically. Both are valid; the right choice depends on whether the safer default is "exclude new fields until reviewed" or "include new fields unless sensitive."

Projections Meet Operations

This connects directly to op from the previous article. Projections work with any function, but combined with op, the access boundary becomes part of the operation's contract. The parameter type on an operation controls what that operation can see. An engineering diagnostic that takes an EngineeringView cannot access weapons targeting data — not by policy, by type:

pub op runDiagnostic =
    payload systems: EngineeringView
    by engineer: CrewMember
    outcomes =
        | DiagnosticComplete of DiagnosticReport
        | SystemDegraded of system: String
        | InsufficientPower
Enter fullscreen mode Exit fullscreen mode

Writing systems.targetingData anywhere in this operation's implementation is a compile error. The field does not exist on EngineeringView. There is no filter to misconfigure and no log to reconstruct.

Medical operations see only medical data:

pub op assessCrewReadiness =
    payload systems: MedicalView
    by medic: CrewMember
    outcomes =
        | AllClear
        | CrewMembersUnfit of count: Int
        | LifeSupportCritical of level: Float
Enter fullscreen mode Exit fullscreen mode

assessCrewReadiness takes a MedicalView. It can read crewBiosigns and lifeSupportLevel. It cannot read weaponsStatus, targetingData, or missionParameters. The constraint is in the type signature, enforced at every call site, visible to any reader. This matters for the three-party model: an agent generating the implementation body of assessCrewReadiness cannot access fields outside the projection, even accidentally. The compiler blocks it the same way it blocks a human who reaches for the wrong field.

What Happens When the Data Model Changes

This is where derivation tracking earns its keep.

Suppose the Enterprise gets a sensor upgrade, and ShipSystems gains a new field:

pub type ShipSystems = {
    -- ... existing fields ...
    subspaceFieldReadings: SubspaceData   -- new field
}
Enter fullscreen mode Exit fullscreen mode

What happens to the projections?

For without-based projections like PublicShipStatus, the new field is included automatically — it wasn't in the exclusion list. If subspaceFieldReadings is sensitive, every view that uses without now needs review. But the compiler knows which projections were affected by the schema change, so the developer can inspect each one and decide whether the new field belongs in the exclusion list.

For only-based projections like EngineeringView, the new field is excluded automatically — it wasn't in the inclusion list. No accidental exposure. If Engineering should see subspace readings, someone adds it explicitly.

Either way, the question "which views can see this new field?" has a definitive, machine-readable answer. The compiler traces the derivation relationship from source type to every projection. No manual audit of filter functions. No hoping someone remembered to update the access layer.

Field Accounting

Projections handle narrowing — taking a type and producing something smaller. Going the other direction is where field accounting kicks in.

If you receive a CrewMember and need to produce a CrewRecord for the ship's database, the relationship between the two types is explicit:

type CrewRecord = CrewMember extending {
    assignedQuarters: String
    dutyShift: DutyShift
    boardingDate: StarDate
}
Enter fullscreen mode Exit fullscreen mode

CrewRecord has all the fields of CrewMember plus three more. The extending keyword declares the derivation — same idea as only and without, but in the widening direction. Now the compiler requires you to account for every field difference when converting between them.

Ruuk's transform keyword defines a conversion between related types — a mapping function with compiler-verified field coverage:

pub transform toCrewRecord (member: CrewMember) (quarters: String)
                           (shift: DutyShift) (date: StarDate) : CrewRecord =
    { member with
        assignedQuarters = quarters
        dutyShift = shift
        boardingDate = date }
Enter fullscreen mode Exit fullscreen mode

The compiler checks this. The transform body must supply those three — and it does. If you forgot boardingDate, the compiler tells you exactly which field is missing and that it came from the gap between CrewMember and CrewRecord.

This is the balance equation: fields from the source plus fields added in the body must equal the fields required in the output. The compiler verifies the equation. You verify the domain logic. The division of labor is clean.

The Stronger Answer

There's a consequence of all this that I didn't anticipate when I started designing projections.

At some point, someone asks: "can Engineering access classified mission parameters?" With manual subset types or runtime filters, answering that question takes work. You trace the type definition or check the filter function, verify the configuration was correct for the period in question, and conclude that access didn't happen. That's a real answer — but it's a statement about observed behavior, and it depends on every link in the verification chain being intact.

With projections, the answer is the source code itself. runDiagnostic takes an EngineeringView, declared as ShipSystems only { hullIntegrity; warpCorePower; auxiliaryPower; lifeSupportLevel; shieldStrength }. The field missionParameters is not in that list. Access is structurally impossible — not unobserved, but inexpressible.

That's a meaningfully different kind of answer. It holds for every execution of the code, past and future, regardless of who or what wrote the implementation — until someone changes the projection declaration, at which point the change appears in version control, goes through review, and is auditable in its own right.

This is the same arc as op. I designed op to enforce error handling commitments under schedule pressure; it turned out to produce exhaustive compiler verification. I designed projections to avoid writing DTOs; they turned out to produce provable access boundaries. In both cases, the practical motivation led somewhere I hadn't planned — the feature I built for convenience became the feature I kept for correctness.

What's Next

Projections control what an operation can see. The next article addresses when an operation can run.

A starship mission moves through stages: proposed, approved, in progress, completed. You shouldn't be able to launch a mission that hasn't been approved, and you shouldn't be able to approve a mission that's already in progress. Today, those constraints live in runtime guards — if mission.status != "approved" then throw. The compiler doesn't know about them, doesn't verify them, and doesn't prevent someone from calling the wrong operation at the wrong time.

Ruuk's resource keyword and typestate move the state machine into the type system. A Mission<Proposed> is a different type from a Mission<Approved>, and the operation that launches a mission only accepts the latter. The compiler catches the violation before the code runs.

Ruuk is pre-alpha. If these ideas resonate, follow along on GitHub and weigh in on the discussions.

Top comments (0)