From 7b749717b0fec41c0ee385fb14cffc6d851fedfb Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sat, 1 Aug 2026 19:42:05 +0200 Subject: [PATCH] add TypeScript type performance checks --- AGENTS.md | 26 ++++++ package.json | 1 + perf/types/define-states.ts | 12 +++ perf/types/effect-only.ts | 9 ++ perf/types/handle.ts | 35 ++++++++ perf/types/import-only.ts | 11 +++ perf/types/make-control.ts | 18 ++++ perf/types/make.ts | 23 ++++++ scripts/type-performance.mjs | 156 +++++++++++++++++++++++++++++++++++ 9 files changed, 291 insertions(+) create mode 100644 AGENTS.md create mode 100644 perf/types/define-states.ts create mode 100644 perf/types/effect-only.ts create mode 100644 perf/types/handle.ts create mode 100644 perf/types/import-only.ts create mode 100644 perf/types/make-control.ts create mode 100644 perf/types/make.ts create mode 100644 scripts/type-performance.mjs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..73de8dc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# Project guidance + +A core objective of the library is type safety and ease of use of the user-facing API, for both humans and agents. + +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`. + +Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later. + +## Feature verification workflow + +Before implementing a feature, run: + +```sh +pnpm perf:types +``` + +Record the type-performance results as the baseline for the feature. + +After implementing the feature, run: + +```sh +pnpm typecheck +pnpm perf:types +``` + +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. diff --git a/package.json b/package.json index c893145..a02e2f7 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "test": "vitest run", "test:types": "tstyche", "typecheck": "tsc -p tsconfig.json --noEmit", + "perf:types": "pnpm build && node scripts/type-performance.mjs", "format": "prettier --write .", "format:check": "prettier --check .", "test:consumer": "node scripts/test-consumer.mjs", diff --git a/perf/types/define-states.ts b/perf/types/define-states.ts new file mode 100644 index 0000000..e1b8684 --- /dev/null +++ b/perf/types/define-states.ts @@ -0,0 +1,12 @@ +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +const State = Schema.TaggedUnion({ + Idle: {}, + Running: {}, + Done: { value: Schema.String } +}) + +const States = Machine.defineStates(State.cases) + +void States diff --git a/perf/types/effect-only.ts b/perf/types/effect-only.ts new file mode 100644 index 0000000..6ae8c45 --- /dev/null +++ b/perf/types/effect-only.ts @@ -0,0 +1,9 @@ +import { Schema } from "effect" + +const State = Schema.TaggedUnion({ + Idle: {}, + Running: {}, + Done: { value: Schema.String } +}) + +void State diff --git a/perf/types/handle.ts b/perf/types/handle.ts new file mode 100644 index 0000000..374da16 --- /dev/null +++ b/perf/types/handle.ts @@ -0,0 +1,35 @@ +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +const State = Schema.TaggedUnion({ + Idle: {}, + Running: {}, + Done: { value: Schema.String } +}) + +const Event = Schema.TaggedUnion({ + Start: {}, + Finish: { value: Schema.String } +}) + +const States = Machine.defineStates(State.cases) + +const machine = Machine.make({ + states: States.states, + events: [Event.cases.Start, Event.cases.Finish], + initial: () => States.initial.Idle(State.cases.Idle.make({})) +}).handle({ + Idle: { + on: { + Start: ({ target }) => target.full.Running(State.cases.Running.make({})) + } + }, + Running: { + on: { + Finish: ({ event, target }) => target.full.Done(State.cases.Done.make({ value: event.value })) + } + }, + Done: {} +}) + +void machine diff --git a/perf/types/import-only.ts b/perf/types/import-only.ts new file mode 100644 index 0000000..ed4b547 --- /dev/null +++ b/perf/types/import-only.ts @@ -0,0 +1,11 @@ +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +const State = Schema.TaggedUnion({ + Idle: {}, + Running: {}, + Done: { value: Schema.String } +}) + +void Machine +void State diff --git a/perf/types/make-control.ts b/perf/types/make-control.ts new file mode 100644 index 0000000..ef1ca54 --- /dev/null +++ b/perf/types/make-control.ts @@ -0,0 +1,18 @@ +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +const State = Schema.TaggedUnion({ + Idle: {}, + Running: {}, + Done: { value: Schema.String } +}) + +const Event = Schema.TaggedUnion({ + Start: {}, + Finish: { value: Schema.String } +}) + +const States = Machine.defineStates(State.cases) + +void Event +void States diff --git a/perf/types/make.ts b/perf/types/make.ts new file mode 100644 index 0000000..d07875c --- /dev/null +++ b/perf/types/make.ts @@ -0,0 +1,23 @@ +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +const State = Schema.TaggedUnion({ + Idle: {}, + Running: {}, + Done: { value: Schema.String } +}) + +const Event = Schema.TaggedUnion({ + Start: {}, + Finish: { value: Schema.String } +}) + +const States = Machine.defineStates(State.cases) + +const machine = Machine.make({ + states: States.states, + events: [Event.cases.Start, Event.cases.Finish], + initial: () => States.initial.Idle(State.cases.Idle.make({})) +}) + +void machine diff --git a/scripts/type-performance.mjs b/scripts/type-performance.mjs new file mode 100644 index 0000000..a437869 --- /dev/null +++ b/scripts/type-performance.mjs @@ -0,0 +1,156 @@ +import { spawnSync } from "node:child_process" +import { resolve } from "node:path" + +const root = resolve(import.meta.dirname, "..") +const tsc = resolve(root, "node_modules", "typescript", "bin", "tsc") + +const scenarios = [ + { + id: "effect-only", + label: "Effect only", + file: "effect-only.ts" + }, + { + id: "import-only", + label: "Import effect-machine", + file: "import-only.ts", + control: "effect-only" + }, + { + id: "define-states", + label: "Machine.defineStates (3 states)", + file: "define-states.ts", + control: "import-only" + }, + { + id: "make-control", + label: "Machine.make setup", + file: "make-control.ts", + hidden: true + }, + { + id: "make", + label: "Machine.make (3 states, 2 events)", + file: "make.ts", + control: "make-control" + }, + { + id: "handle", + label: "machine.handle (3 states, 2 transitions)", + file: "handle.ts", + control: "make" + } +] + +const compilerArguments = [ + "--ignoreConfig", + "--noEmit", + "--incremental", + "false", + "--strict", + "--skipLibCheck", + "true", + "--target", + "ES2022", + "--module", + "NodeNext", + "--moduleResolution", + "NodeNext", + "--verbatimModuleSyntax", + "true", + "--exactOptionalPropertyTypes", + "true", + "--lib", + "ES2022", + "--pretty", + "false", + "--extendedDiagnostics" +] + +const run = (args) => { + const result = spawnSync(process.execPath, [tsc, ...args], { + cwd: root, + encoding: "utf8" + }) + + if (result.status !== 0) { + throw new Error([result.stdout?.trim(), result.stderr?.trim()].filter(Boolean).join("\n")) + } + + return result.stdout +} + +const readMetric = (output, name) => { + const match = output.match(new RegExp(`^${name}:\\s+([0-9.]+)`, "m")) + if (match === null) { + throw new Error(`TypeScript did not report the ${name} metric`) + } + return Number(match[1]) +} + +const version = run(["--version"]) + .trim() + .replace(/^Version\s+/, "") +const results = new Map() + +for (const scenario of scenarios) { + const output = run([...compilerArguments, resolve(root, "perf", "types", scenario.file)]) + + results.set(scenario.id, { + instantiations: readMetric(output, "Instantiations"), + checkTime: readMetric(output, "Check time") + }) +} + +const visibleScenarios = scenarios.filter((scenario) => scenario.hidden !== true) +const rows = visibleScenarios.map((scenario) => { + const result = results.get(scenario.id) + const control = scenario.control === undefined ? undefined : results.get(scenario.control) + const delta = control === undefined ? undefined : result.instantiations - control.instantiations + + return { + scenario: scenario.label, + instantiations: result.instantiations.toLocaleString("en-US"), + delta: delta === undefined ? "baseline" : `${delta >= 0 ? "+" : ""}${delta.toLocaleString("en-US")}`, + checkTime: `${result.checkTime.toFixed(2)}s` + } +}) + +const widths = { + scenario: Math.max("Scenario".length, ...rows.map((row) => row.scenario.length)), + instantiations: Math.max("Instantiations".length, ...rows.map((row) => row.instantiations.length)), + delta: Math.max("Marginal".length, ...rows.map((row) => row.delta.length)), + checkTime: Math.max("Check time".length, ...rows.map((row) => row.checkTime.length)) +} + +const formatRow = (row) => + [ + row.scenario.padEnd(widths.scenario), + row.instantiations.padStart(widths.instantiations), + row.delta.padStart(widths.delta), + row.checkTime.padStart(widths.checkTime) + ].join(" ") + +console.log(`Type performance (TypeScript ${version}, skipLibCheck=true)\n`) +console.log( + formatRow({ + scenario: "Scenario", + instantiations: "Instantiations", + delta: "Marginal", + checkTime: "Check time" + }) +) +console.log( + formatRow({ + scenario: "-".repeat(widths.scenario), + instantiations: "-".repeat(widths.instantiations), + delta: "-".repeat(widths.delta), + checkTime: "-".repeat(widths.checkTime) + }) +) +for (const row of rows) { + console.log(formatRow(row)) +} + +console.log("\nMarginal is measured against the matching setup without that API call.") +console.log("Check time is informational; instantiations are the stable comparison metric.")