Skip to content

Commit bfb73a9

Browse files
committed
cleaner
1 parent 85a3022 commit bfb73a9

1 file changed

Lines changed: 50 additions & 211 deletions

File tree

docs/blog/posts/jac_vs_sota_todo_app.md

Lines changed: 50 additions & 211 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ categories:
88
slug: jac-vs-sota-todo-app
99
---
1010

11-
# Same App, 4x the Code: Anatomy of a Full-Stack Polyglot Tax
11+
# Same App, 5x the Code: Anatomy of a Full-Stack Polyglot Tax
1212

1313
We built the exact same app twice. Once in Jac. Once with FastAPI, SQLAlchemy, LangChain, React, TypeScript, Vite, and Bun — the state-of-the-art (SOTA) stack that a senior engineer would reach for today. Same features, same UI, same AI-powered categorization, same persistence. Both QA-verified to behave identically.
1414

15-
The Jac version is **2 files, 82 lines**. The SOTA version is **16 files, 311 lines**.
15+
The Jac version is **1 file, 46 lines** of application code. The SOTA version is **11 files, 233 lines**.
1616

1717
These are good tools. FastAPI is arguably the best Python API framework. SQLAlchemy is the most mature Python ORM. LangChain is the dominant LLM orchestration library. React and TypeScript need no introduction. We picked the best of each category on purpose — this isn't a comparison against straw men.
1818

19-
And yet, building with the best-in-class version of every tool still produces nearly 4x more code across 8x more files than expressing the same idea in a language designed to handle the full stack. We're calling this the **polyglot tax** — the code you write not to solve your problem, but to make your tools talk to each other across language, runtime, and type-system boundaries. This article is a line-by-line anatomy of where that tax shows up and what it costs.
19+
And yet, building with the best-in-class version of every tool still produces ~5x more application code across 11x more files than expressing the same idea in a language designed to handle the full stack. We're calling this the **polyglot tax** — the code you write not to solve your problem, but to make your tools talk to each other across language, runtime, and type-system boundaries. This article is a line-by-line anatomy of where that tax shows up and what it costs.
2020

2121
<!-- more -->
2222

@@ -39,13 +39,9 @@ def categorize(title: str) -> Category by llm();
3939
4040
def:pub add_todo(title: str) -> Todo {
4141
try {
42-
result = categorize(title);
43-
category = str(result).split(".")[-1].lower();
44-
} except Exception {
45-
category = "other (setup AI key)";
46-
}
47-
todo = Todo(title=title, category=category);
48-
root() ++> todo;
42+
category = str(categorize(title)).split(".")[-1].lower();
43+
} except Exception { }
44+
root() ++> (todo := Todo(title=title, category=category));
4945
return todo;
5046
}
5147
@@ -74,19 +70,11 @@ cl def:pub app -> JsxElement {
7470
<input
7571
value={text}
7672
onChange={lambda e: ChangeEvent { text = e.target.value;}}
77-
onKeyPress={lambda e: KeyboardEvent { if e.key == "Enter" {
78-
add();
79-
}}}
73+
onKeyPress={lambda e: KeyboardEvent { if e.key == "Enter" { add(); }}}
8074
placeholder="Add a todo..."
8175
/>
82-
<button onClick={add}>
83-
Add
84-
</button>
85-
{[
86-
<p key={jid(t)}>
87-
{t.title} ({t.category})
88-
</p> for t in todos
89-
]}
76+
<button onClick={add}>Add</button>
77+
{[<p key={jid(t)}>{t.title} ({t.category})</p> for t in todos]}
9078
</div>;
9179
}
9280
```
@@ -244,18 +232,14 @@ The deeper issue is about what the right interface to an LLM looks like. A type
244232

245233
## Section 3: The API Layer
246234

247-
### Jac — 12 lines
235+
### Jac — 10 lines
248236

249237
```jac
250238
def:pub add_todo(title: str) -> Todo {
251239
try {
252-
result = categorize(title);
253-
category = str(result).split(".")[-1].lower();
254-
} except Exception {
255-
category = "other (setup AI key)";
256-
}
257-
todo = Todo(title=title, category=category);
258-
root() ++> todo;
240+
category = str(categorize(title)).split(".")[-1].lower();
241+
} except Exception { }
242+
root() ++> (todo := Todo(title=title, category=category));
259243
return todo;
260244
}
261245
@@ -363,7 +347,7 @@ That last part — `TodoResponse(id=todo.id, title=todo.title, ...)` — is part
363347

364348
**Static file serving** (lines 64-69) — The backend serving the frontend's compiled assets. This exists because the frontend is a separate application. In Jac, `cl def` compiles into the server's asset pipeline — there's nothing to serve separately.
365349

366-
**Expansion factor: ~5.8x**
350+
**Expansion factor: ~7x**
367351

368352
---
369353

@@ -393,19 +377,11 @@ cl def:pub app -> JsxElement {
393377
<input
394378
value={text}
395379
onChange={lambda e: ChangeEvent { text = e.target.value;}}
396-
onKeyPress={lambda e: KeyboardEvent { if e.key == "Enter" {
397-
add();
398-
}}}
380+
onKeyPress={lambda e: KeyboardEvent { if e.key == "Enter" { add(); }}}
399381
placeholder="Add a todo..."
400382
/>
401-
<button onClick={add}>
402-
Add
403-
</button>
404-
{[
405-
<p key={jid(t)}>
406-
{t.title} ({t.category})
407-
</p> for t in todos
408-
]}
383+
<button onClick={add}>Add</button>
384+
{[<p key={jid(t)}>{t.title} ({t.category})</p> for t in todos]}
409385
</div>;
410386
}
411387
```
@@ -636,183 +612,19 @@ Seven dependencies for a todo app frontend. Notice `@types/react` and `@types/re
636612

637613
---
638614

639-
## Section 5: Project Configuration — One Config vs Five
640-
641-
Every project needs configuration. Dependencies, build settings, compiler options, runtime behavior. The question is how many separate config systems you're managing.
642-
643-
### Jac — 34 lines, 1 file
644-
645-
```toml
646-
[project]
647-
name = "mini-todo"
648-
version = "1.0.0"
649-
description = "Minimal full-stack AI todo app"
650-
entry-point = "main.jac"
651-
652-
[dependencies.npm]
653-
react = "^18.2.0"
654-
react-dom = "^18.2.0"
655-
656-
[dependencies.npm.dev]
657-
vite = "^6.4.1"
658-
"@vitejs/plugin-react" = "^4.2.1"
659-
typescript = "^5.3.3"
660-
"@types/react" = "^18.2.0"
661-
"@types/react-dom" = "^18.2.0"
662-
663-
[dev-dependencies]
664-
watchdog = ">=3.0.0"
665-
666-
[serve]
667-
base_route_app = "app"
668-
669-
[plugins.scale]
670-
671-
[plugins.client]
672-
673-
[plugins.byllm.model]
674-
default_model = "claude-sonnet-4-20250514"
675-
```
676-
677-
One file declares everything: Python dependencies, npm dependencies, dev dependencies, server config, plugin config, and LLM model selection. `jac start` reads this and does the rest — installs packages across both ecosystems, builds the frontend, starts the server.
678-
679-
The key observation: both Python and JavaScript dependencies live in the same file. There's no cognitive split between "backend config" and "frontend config" because there's no backend/frontend split.
680-
681-
### SOTA — 78 lines across 5 files
682-
683-
The same concerns are spread across five files in two different ecosystems.
684-
685-
**`pyproject.toml`** — Python dependencies:
686-
687-
```toml
688-
[project]
689-
name = "mini-todo-sota"
690-
version = "1.0.0"
691-
description = "Minimal full-stack AI todo app (SOTA stack)"
692-
requires-python = ">=3.12"
693-
dependencies = [
694-
"fastapi>=0.121.0",
695-
"uvicorn>=0.38.0",
696-
"sqlalchemy>=2.0.49",
697-
"aiosqlite>=0.22.0",
698-
"langchain>=1.2.0",
699-
"langchain-anthropic>=1.4.0",
700-
]
701-
```
702-
703-
Six runtime dependencies for the backend alone: a web framework, a server, an ORM, a database driver, and two LangChain packages. Each of these pulls in its own transitive dependency tree. Jac's backend has zero Python dependencies beyond the Jac runtime itself — the framework, ORM, and LLM integration are language features, not library choices.
704-
705-
**`frontend/package.json`** — JavaScript dependencies and build scripts:
706-
707-
```json
708-
{
709-
"name": "frontend",
710-
"private": true,
711-
"version": "0.0.0",
712-
"type": "module",
713-
"scripts": {
714-
"dev": "vite",
715-
"build": "tsc -b && vite build",
716-
"preview": "vite preview"
717-
},
718-
"dependencies": {
719-
"react": "^19.2.4",
720-
"react-dom": "^19.2.4"
721-
},
722-
"devDependencies": {
723-
"@types/react": "^19.2.14",
724-
"@types/react-dom": "^19.2.3",
725-
"@vitejs/plugin-react": "^6.0.1",
726-
"typescript": "~6.0.2",
727-
"vite": "^8.0.4"
728-
}
729-
}
730-
```
731-
732-
Seven npm dependencies. The `scripts` section defines build commands — `"build": "tsc -b && vite build"` chains two separate tools. The `@types/*` packages exist because React's runtime and its type definitions are maintained by different teams.
733-
734-
**`frontend/vite.config.ts`** — Build tool configuration:
735-
736-
```typescript
737-
import { defineConfig } from 'vite'
738-
import react from '@vitejs/plugin-react'
739-
740-
export default defineConfig({
741-
plugins: [react()],
742-
server: {
743-
port: 3000,
744-
proxy: {
745-
'/api': 'http://localhost:8001',
746-
},
747-
},
748-
})
749-
```
750-
751-
The proxy config is the tell: `'/api': 'http://localhost:8001'` exists because the frontend and backend are separate processes. This is configuration that manages the seam between two applications pretending to be one.
752-
753-
**`frontend/tsconfig.json`** and **`frontend/tsconfig.app.json`** — TypeScript compiler configuration:
754-
755-
```json
756-
{
757-
"files": [],
758-
"references": [
759-
{ "path": "./tsconfig.app.json" }
760-
]
761-
}
762-
```
763-
764-
```json
765-
{
766-
"compilerOptions": {
767-
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
768-
"target": "es2023",
769-
"lib": ["ES2023", "DOM", "DOM.Iterable"],
770-
"module": "esnext",
771-
"types": ["vite/client"],
772-
"skipLibCheck": true,
773-
"moduleResolution": "bundler",
774-
"allowImportingTsExtensions": true,
775-
"verbatimModuleSyntax": true,
776-
"moduleDetection": "force",
777-
"noEmit": true,
778-
"jsx": "react-jsx",
779-
"noUnusedLocals": true,
780-
"noUnusedParameters": true,
781-
"erasableSyntaxOnly": true,
782-
"noFallthroughCasesInSwitch": true
783-
},
784-
"include": ["src"]
785-
}
786-
```
787-
788-
31 lines configuring how TypeScript compiles. Target version, module resolution, JSX transform, linting rules. Every setting is a negotiation between TypeScript, Vite, and React. Projects copy these from templates and tweak them, because getting the combination right from scratch requires understanding how three tools interact.
789-
790-
### What this reveals
791-
792-
The Jac config is one file because Jac owns the full stack. It doesn't need to negotiate between independent tools because there aren't independent tools to negotiate between.
793-
794-
The SOTA config is five files because five tools need to be told about each other. The `pyproject.toml` doesn't know about `package.json`. The `tsconfig` doesn't know about `pyproject.toml`. The `vite.config.ts` needs to know about both the TypeScript compiler and the backend server. Each file is a point of configuration for a tool that was designed in isolation, and the developer's job is to make them agree.
795-
796-
This is the polyglot tax applied to configuration. Each tool is well-designed on its own. The tax comes from the spaces between them.
797-
798-
**Expansion factor: ~2.3x**
799-
800-
---
801-
802615
## The Full Scorecard
803616

804617
| Section | Jac | SOTA | Factor |
805618
|---------|-----|------|--------|
806619
| Data model | 5 lines | 14 lines, 1 file | 2.8x |
807620
| AI categorization | 3 lines | 48 lines, 1 file | 16x |
808-
| API + server | 12 lines | 70 lines, 1 file | 5.8x |
621+
| API + server | 10 lines | 70 lines, 1 file | 7x |
809622
| Frontend | 28 lines | 101 lines, 8 files | 3.6x |
810-
| Configuration | 34 lines, 1 file | 78 lines, 5 files | 2.3x |
811-
| **Total** | **82 lines, 2 files** | **311 lines, 16 files** | **3.8x**
623+
| **Total** | **46 lines, 1 file** | **233 lines, 11 files** | **~5x**
812624

813625
---
814626

815-
## Where the Extra 229 Lines Come From
627+
## Where the Extra 187 Lines Come From
816628

817629
The extra code isn't random — it falls into clear categories:
818630

@@ -828,7 +640,7 @@ None of this code is *wrong*. Every line exists for a reason. The SOTA tools are
828640

829641
## Types Across Every Boundary
830642

831-
The thread that connects all five sections is **types**, and what happens to them at boundaries.
643+
The thread that connects all four sections is **types**, and what happens to them at boundaries.
832644

833645
In the SOTA version, the `Todo` type exists in four representations: a SQLAlchemy class, a Pydantic model, a TypeScript interface, and (implicitly) the structure the LLM prompt describes in English. These representations are maintained independently, in different languages, by different tools. When they agree, the app works. When they drift, the bugs are silent — wrong fields, missing data, miscategorized items — because the failures happen at runtime across process boundaries where no compiler is watching.
834646

@@ -851,7 +663,7 @@ Both versions are open source and runnable. The full code lives at [github.com/m
851663
- **`jac/`** — The Jac version. Run with `jac start`.
852664
- **`sota/`** — The SOTA version. Run with `./run.sh` (requires Python 3.12+, Bun, and an `ANTHROPIC_API_KEY`).
853665

854-
We QA-tested both with the same automated browser test suite — add via button, add via Enter, empty/whitespace rejection, XSS safety, AI categorization across all five categories, and data persistence after reload. Both pass every test. Same app, same behavior, same results. One takes 82 lines and the other takes 311.
666+
We QA-tested both with the same automated browser test suite — add via button, add via Enter, empty/whitespace rejection, XSS safety, AI categorization across all five categories, and data persistence after reload. Both pass every test. Same app, same behavior, same results. One takes 46 lines and the other takes 233.
855667

856668
## What This Comparison Is and Isn't
857669

@@ -863,4 +675,31 @@ Every era of developer tooling has eliminated a category of this kind of code. C
863675

864676
Jac eliminates the polyglot tax by eliminating the polyglot. One language across storage, API, frontend, and AI. It's not magic — it's a compiler that owns the full stack, so it can enforce types and generate plumbing across boundaries that no single SOTA tool can see.
865677

866-
The 229 extra lines aren't wrong. They're just not doing what you hired them to do.
678+
The 187 extra lines aren't wrong. They're just not doing what you hired them to do.
679+
680+
---
681+
682+
## Bonus: The Configuration Tax
683+
684+
The scorecard above covers application code only. But the polyglot tax extends to project configuration too — and the ratio there tells its own story.
685+
686+
### Jac — 34 lines, 1 file
687+
688+
`jac.toml` declares everything in one place: Python dependencies, npm dependencies, dev dependencies, server config, plugin config, and LLM model selection. Both ecosystems, one file. `jac start` reads it and does the rest.
689+
690+
### SOTA — 78 lines across 5 files
691+
692+
The same concerns are spread across five files in two ecosystems:
693+
694+
- **`pyproject.toml`** (13 lines) — Six Python runtime dependencies: web framework, server, ORM, database driver, two LangChain packages.
695+
- **`frontend/package.json`** (22 lines) — Seven npm dependencies. Build scripts chain two tools: `"build": "tsc -b && vite build"`.
696+
- **`frontend/vite.config.ts`** (12 lines) — The dev proxy `'/api': 'http://localhost:8001'` exists because frontend and backend are separate processes.
697+
- **`frontend/tsconfig.json`** + **`frontend/tsconfig.app.json`** (31 lines) — TypeScript compiler configuration negotiating between TypeScript, Vite, and React.
698+
699+
### The pattern
700+
701+
The Jac config is one file because Jac owns the full stack. The SOTA config is five files because five tools need to be told about each other. The `pyproject.toml` doesn't know about `package.json`. The `tsconfig` doesn't know about `pyproject.toml`. The `vite.config.ts` needs to know about both. Each file is a point of configuration for a tool designed in isolation, and the developer's job is to make them agree.
702+
703+
**Configuration expansion: 34 lines → 78 lines (2.3x), 1 file → 5 files.**
704+
705+
Including configuration, the full totals become **80 lines, 2 files** (Jac) vs **311 lines, 16 files** (SOTA) — still roughly a 4x gap.

0 commit comments

Comments
 (0)