Skip to content

Commit 7b74971

Browse files
add TypeScript type performance checks
1 parent 4ba1f90 commit 7b74971

9 files changed

Lines changed: 291 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Project guidance
2+
3+
A core objective of the library is type safety and ease of use of the user-facing API, for both humans and agents.
4+
5+
The goal is eventually to merge this inside the core of the `effect` library, so plan changes according to the patterns and expectations of `effect`.
6+
7+
Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.
8+
9+
## Feature verification workflow
10+
11+
Before implementing a feature, run:
12+
13+
```sh
14+
pnpm perf:types
15+
```
16+
17+
Record the type-performance results as the baseline for the feature.
18+
19+
After implementing the feature, run:
20+
21+
```sh
22+
pnpm typecheck
23+
pnpm perf:types
24+
```
25+
26+
Compare the final type-performance results with the baseline. When reporting the completed work, include the before and after results and call out the additional type-instantiation cost of the feature, including regressions or improvements.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"test": "vitest run",
4646
"test:types": "tstyche",
4747
"typecheck": "tsc -p tsconfig.json --noEmit",
48+
"perf:types": "pnpm build && node scripts/type-performance.mjs",
4849
"format": "prettier --write .",
4950
"format:check": "prettier --check .",
5051
"test:consumer": "node scripts/test-consumer.mjs",

perf/types/define-states.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Machine } from "@typeonce/effect-machine"
2+
import { Schema } from "effect"
3+
4+
const State = Schema.TaggedUnion({
5+
Idle: {},
6+
Running: {},
7+
Done: { value: Schema.String }
8+
})
9+
10+
const States = Machine.defineStates(State.cases)
11+
12+
void States

perf/types/effect-only.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Schema } from "effect"
2+
3+
const State = Schema.TaggedUnion({
4+
Idle: {},
5+
Running: {},
6+
Done: { value: Schema.String }
7+
})
8+
9+
void State

perf/types/handle.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Machine } from "@typeonce/effect-machine"
2+
import { Schema } from "effect"
3+
4+
const State = Schema.TaggedUnion({
5+
Idle: {},
6+
Running: {},
7+
Done: { value: Schema.String }
8+
})
9+
10+
const Event = Schema.TaggedUnion({
11+
Start: {},
12+
Finish: { value: Schema.String }
13+
})
14+
15+
const States = Machine.defineStates(State.cases)
16+
17+
const machine = Machine.make({
18+
states: States.states,
19+
events: [Event.cases.Start, Event.cases.Finish],
20+
initial: () => States.initial.Idle(State.cases.Idle.make({}))
21+
}).handle({
22+
Idle: {
23+
on: {
24+
Start: ({ target }) => target.full.Running(State.cases.Running.make({}))
25+
}
26+
},
27+
Running: {
28+
on: {
29+
Finish: ({ event, target }) => target.full.Done(State.cases.Done.make({ value: event.value }))
30+
}
31+
},
32+
Done: {}
33+
})
34+
35+
void machine

perf/types/import-only.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Machine } from "@typeonce/effect-machine"
2+
import { Schema } from "effect"
3+
4+
const State = Schema.TaggedUnion({
5+
Idle: {},
6+
Running: {},
7+
Done: { value: Schema.String }
8+
})
9+
10+
void Machine
11+
void State

perf/types/make-control.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Machine } from "@typeonce/effect-machine"
2+
import { Schema } from "effect"
3+
4+
const State = Schema.TaggedUnion({
5+
Idle: {},
6+
Running: {},
7+
Done: { value: Schema.String }
8+
})
9+
10+
const Event = Schema.TaggedUnion({
11+
Start: {},
12+
Finish: { value: Schema.String }
13+
})
14+
15+
const States = Machine.defineStates(State.cases)
16+
17+
void Event
18+
void States

perf/types/make.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Machine } from "@typeonce/effect-machine"
2+
import { Schema } from "effect"
3+
4+
const State = Schema.TaggedUnion({
5+
Idle: {},
6+
Running: {},
7+
Done: { value: Schema.String }
8+
})
9+
10+
const Event = Schema.TaggedUnion({
11+
Start: {},
12+
Finish: { value: Schema.String }
13+
})
14+
15+
const States = Machine.defineStates(State.cases)
16+
17+
const machine = Machine.make({
18+
states: States.states,
19+
events: [Event.cases.Start, Event.cases.Finish],
20+
initial: () => States.initial.Idle(State.cases.Idle.make({}))
21+
})
22+
23+
void machine

scripts/type-performance.mjs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { spawnSync } from "node:child_process"
2+
import { resolve } from "node:path"
3+
4+
const root = resolve(import.meta.dirname, "..")
5+
const tsc = resolve(root, "node_modules", "typescript", "bin", "tsc")
6+
7+
const scenarios = [
8+
{
9+
id: "effect-only",
10+
label: "Effect only",
11+
file: "effect-only.ts"
12+
},
13+
{
14+
id: "import-only",
15+
label: "Import effect-machine",
16+
file: "import-only.ts",
17+
control: "effect-only"
18+
},
19+
{
20+
id: "define-states",
21+
label: "Machine.defineStates (3 states)",
22+
file: "define-states.ts",
23+
control: "import-only"
24+
},
25+
{
26+
id: "make-control",
27+
label: "Machine.make setup",
28+
file: "make-control.ts",
29+
hidden: true
30+
},
31+
{
32+
id: "make",
33+
label: "Machine.make (3 states, 2 events)",
34+
file: "make.ts",
35+
control: "make-control"
36+
},
37+
{
38+
id: "handle",
39+
label: "machine.handle (3 states, 2 transitions)",
40+
file: "handle.ts",
41+
control: "make"
42+
}
43+
]
44+
45+
const compilerArguments = [
46+
"--ignoreConfig",
47+
"--noEmit",
48+
"--incremental",
49+
"false",
50+
"--strict",
51+
"--skipLibCheck",
52+
"true",
53+
"--target",
54+
"ES2022",
55+
"--module",
56+
"NodeNext",
57+
"--moduleResolution",
58+
"NodeNext",
59+
"--verbatimModuleSyntax",
60+
"true",
61+
"--exactOptionalPropertyTypes",
62+
"true",
63+
"--lib",
64+
"ES2022",
65+
"--pretty",
66+
"false",
67+
"--extendedDiagnostics"
68+
]
69+
70+
const run = (args) => {
71+
const result = spawnSync(process.execPath, [tsc, ...args], {
72+
cwd: root,
73+
encoding: "utf8"
74+
})
75+
76+
if (result.status !== 0) {
77+
throw new Error([result.stdout?.trim(), result.stderr?.trim()].filter(Boolean).join("\n"))
78+
}
79+
80+
return result.stdout
81+
}
82+
83+
const readMetric = (output, name) => {
84+
const match = output.match(new RegExp(`^${name}:\\s+([0-9.]+)`, "m"))
85+
if (match === null) {
86+
throw new Error(`TypeScript did not report the ${name} metric`)
87+
}
88+
return Number(match[1])
89+
}
90+
91+
const version = run(["--version"])
92+
.trim()
93+
.replace(/^Version\s+/, "")
94+
const results = new Map()
95+
96+
for (const scenario of scenarios) {
97+
const output = run([...compilerArguments, resolve(root, "perf", "types", scenario.file)])
98+
99+
results.set(scenario.id, {
100+
instantiations: readMetric(output, "Instantiations"),
101+
checkTime: readMetric(output, "Check time")
102+
})
103+
}
104+
105+
const visibleScenarios = scenarios.filter((scenario) => scenario.hidden !== true)
106+
const rows = visibleScenarios.map((scenario) => {
107+
const result = results.get(scenario.id)
108+
const control = scenario.control === undefined ? undefined : results.get(scenario.control)
109+
const delta = control === undefined ? undefined : result.instantiations - control.instantiations
110+
111+
return {
112+
scenario: scenario.label,
113+
instantiations: result.instantiations.toLocaleString("en-US"),
114+
delta: delta === undefined ? "baseline" : `${delta >= 0 ? "+" : ""}${delta.toLocaleString("en-US")}`,
115+
checkTime: `${result.checkTime.toFixed(2)}s`
116+
}
117+
})
118+
119+
const widths = {
120+
scenario: Math.max("Scenario".length, ...rows.map((row) => row.scenario.length)),
121+
instantiations: Math.max("Instantiations".length, ...rows.map((row) => row.instantiations.length)),
122+
delta: Math.max("Marginal".length, ...rows.map((row) => row.delta.length)),
123+
checkTime: Math.max("Check time".length, ...rows.map((row) => row.checkTime.length))
124+
}
125+
126+
const formatRow = (row) =>
127+
[
128+
row.scenario.padEnd(widths.scenario),
129+
row.instantiations.padStart(widths.instantiations),
130+
row.delta.padStart(widths.delta),
131+
row.checkTime.padStart(widths.checkTime)
132+
].join(" ")
133+
134+
console.log(`Type performance (TypeScript ${version}, skipLibCheck=true)\n`)
135+
console.log(
136+
formatRow({
137+
scenario: "Scenario",
138+
instantiations: "Instantiations",
139+
delta: "Marginal",
140+
checkTime: "Check time"
141+
})
142+
)
143+
console.log(
144+
formatRow({
145+
scenario: "-".repeat(widths.scenario),
146+
instantiations: "-".repeat(widths.instantiations),
147+
delta: "-".repeat(widths.delta),
148+
checkTime: "-".repeat(widths.checkTime)
149+
})
150+
)
151+
for (const row of rows) {
152+
console.log(formatRow(row))
153+
}
154+
155+
console.log("\nMarginal is measured against the matching setup without that API call.")
156+
console.log("Check time is informational; instantiations are the stable comparison metric.")

0 commit comments

Comments
 (0)