Build a typed data processor¶
This tutorial builds a small JSON-in/JSON-out order processor. It combines models, JSON derives, file I/O, error handling, modules, collection transforms, and tests in one executable project.
- ModelDefine input and output contracts
- TransformKeep valid orders and calculate totals
- ConnectRead and write JSON at the boundary
- TestExercise the pure transformation
- RunTest and produce the report
The complete example lives at examples/advanced/typed_data_processor in the Incan repository.
Step 1: Create the project¶
Use this layout:
typed_data_processor/
├── incan.toml
├── orders.json
├── src/
│ ├── domain.incn
│ ├── transform.incn
│ └── main.incn
└── tests/
└── test_transform.incn
The separation is intentional: domain.incn owns contracts, transform.incn stays pure, and main.incn owns filesystem effects.
Step 2: Define typed contracts¶
Create src/domain.incn:
from std.serde import json
@derive(Debug, Clone, json) # (1)
pub model Order: # (2)
"""One order read from the external JSON input contract."""
pub id: str
pub product: str
pub quantity: int
pub unit_price: float
@derive(Debug, Clone, json)
pub model OrderBatch:
"""The typed collection accepted by the processor."""
pub orders: list[Order] # (3)
@derive(Debug, Clone, json)
pub model OrderSummary:
"""A validated order projected into the output report."""
pub id: str
pub product: str
pub total: float
@derive(Debug, Clone, json)
pub model OrderReport:
"""Accepted order summaries plus the number of rejected inputs."""
pub accepted: list[OrderSummary] # (4)
pub rejected_count: int
@derive(...)asks the compiler to generate debugging, cloning, and typed JSON support for the model.modeldeclares a data-first type;pubmakes the type available to the other modules in this project.- Collection types use brackets: this public field contains a
listwhose elements must all beOrdervalues. Fields are markedpubbecause the transform and entry-point modules read them. - The output contract is typed too. Invalid input cannot silently leak into the accepted-order list.
@derive(json) supplies typed serialization and deserialization. The JSON boundary is checked against these model fields instead of being passed through as an unstructured dictionary.
Step 3: Write the transformation¶
Create src/transform.incn:
from domain import OrderBatch, OrderReport, OrderSummary
pub def order_total(quantity: int, unit_price: float) -> float: # (1)
"""Return the monetary total for a quantity and unit price."""
return float(quantity) * unit_price
pub def is_acceptable(quantity: int, unit_price: float) -> bool: # (2)
"""Accept only positive quantities with non-negative prices."""
return quantity > 0 and unit_price >= 0.0
pub def build_report(batch: OrderBatch) -> OrderReport: # (3)
"""Transform one input batch into accepted summaries and a rejection count."""
mut accepted: list[OrderSummary] = []
mut rejected_count = 0
for order in batch.orders:
if is_acceptable(order.quantity, order.unit_price):
accepted.append(OrderSummary( # (4)
id=order.id,
product=order.product,
total=order_total(order.quantity, order.unit_price),
))
else:
rejected_count += 1
return OrderReport(accepted=accepted, rejected_count=rejected_count) # (5)
order_totalisolates the numeric calculation behind a small typed function.is_acceptablegives the validation rule one name and one implementation.- The signature makes the whole transformation contract visible: typed input in, typed report out.
- Accepted rows become
OrderSummaryvalues immediately rather than loose dictionaries. - Model construction uses named arguments, so the returned fields remain clear at the call site. Mutation remains explicit: without
mut, appending toacceptedwould be rejected.
This function knows nothing about files. Keeping the domain transformation pure makes it straightforward to test and reuse.
Step 4: Connect the file boundary¶
In src/main.incn, read the source through std.fs.Path, parse it through the model, and write the derived report. Each map_err converts a boundary-specific error into a useful message, while ? propagates it without nested match blocks:
from domain import OrderBatch, OrderReport
from transform import build_report
from std.fs import Path
from std.serde.json import Deserialize, Serialize
def create_report() -> Result[OrderReport, str]: # (1)
input_path = Path("orders.json")
output_dir = Path("target/tutorial-output")
output_path = output_dir / "order-report.json" # (2)
source = input_path
.read_text("utf-8", "strict")
.map_err((error) => f"Could not read orders.json: {error.message()}")? # (3)
batch = OrderBatch.from_json(source).map_err((error) => f"Invalid order data: {error}")?
report = build_report(batch)
output_dir
.mkdir(parents=true, exist_ok=true)
.map_err((error) => f"Could not prepare output directory: {error.message()}")?
output_path
.write_text(report.to_json(), "utf-8", "strict", None) # (4)
.map_err((error) => f"Could not write report: {error.message()}")?
return Ok(report) # (5)
def main() -> None:
match create_report(): # (6)
Err(error) => println(error)
Ok(report) =>
println(f"Wrote {len(report.accepted)} accepted orders to order-report.json")
println(f"Rejected {report.rejected_count} invalid order(s)")
Result[OrderReport, str]means success carries anOrderReport, while failure carries a readable error string.Pathoverloads/to join path segments without manual string concatenation.map_errtranslates the filesystem error; the trailing?returns that error immediately or unwraps the successful text.to_json()serializes the typed report before the filesystem boundary writes it.Ok(report)wraps the successful value in the success branch ofResult.- After the sequential work is complete, one
matchhandles the two outcomes and performs the program's visible side effects.
The boundaries remain explicit—filesystem operations return IoError, while typed JSON parsing returns a JSON error—but map_err gives the sequential workflow one error type. The final match is reserved for the point where the program actually handles success or failure.
Step 5: Test the transformation¶
Create tests/test_transform.incn:
from domain import Order, OrderBatch
from transform import build_report, is_acceptable, order_total
from std.testing import assert_eq
def test_primitive_helpers() -> None:
assert_eq(order_total(2, 50.0), 100.0)
assert_eq(is_acceptable(2, 50.0), true)
assert_eq(is_acceptable(0, 50.0), false)
assert_eq(is_acceptable(2, -1.0), false)
def test_build_report_keeps_valid_orders() -> None: # (1)
batch = OrderBatch(orders=[ # (2)
Order(id="A-1", product="keyboard", quantity=2, unit_price=50.0),
Order(id="A-2", product="invalid", quantity=0, unit_price=12.0),
])
report = build_report(batch)
assert_eq(len(report.accepted), 1) # (3)
assert_eq(report.accepted[0].total, 100.0)
assert_eq(report.rejected_count, 1)
- Test discovery recognizes functions whose names begin with
test_. - The test exercises the same typed input contract as production code, including one deliberately invalid order.
- Incan's standard testing assertions compare actual and expected values and report failures with source context.
Prepare, test, and run the complete project:
cd examples/advanced/typed_data_processor
incan oven bake --project . --format json
incan test --locked
incan run --locked
The explicit bake seals any project-specific extension over the immutable full-standard-library Loaf. Normal test and run commands then reuse that checked closure without invoking Cargo. The program writes target/tutorial-output/order-report.json.
You ran a multi-module processor whose filesystem boundary is fallible, whose JSON boundary is typed, and whose transformation is isolated for testing. The generated report is the concrete completion artifact.