Last active
February 17, 2026 06:40
-
-
Save mikesol/66e07b2f36fd7239b02093f36ec2448f to your computer and use it in GitHub Desktop.
Spike: end-to-end phantom kind propagation (validates #233)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Spike: end-to-end phantom kind propagation with branded Interpreter. | |
| * Validates the approach described in #233 on a small scale. | |
| * | |
| * Run: npx tsc --noEmit (uses tsconfig.json) | |
| */ | |
| // ============================================================ | |
| // Typed node interfaces | |
| // ============================================================ | |
| interface TypedNode<T = unknown> { | |
| readonly kind: string; | |
| readonly __T?: T; | |
| } | |
| interface NumAddNode extends TypedNode<number> { | |
| readonly kind: "num/add"; | |
| left: TypedNode<number>; | |
| right: TypedNode<number>; | |
| } | |
| interface NumSubNode extends TypedNode<number> { | |
| readonly kind: "num/sub"; | |
| left: TypedNode<number>; | |
| right: TypedNode<number>; | |
| } | |
| interface StrUpperNode extends TypedNode<string> { | |
| readonly kind: "str/upper"; | |
| inner: TypedNode<string>; | |
| } | |
| // ============================================================ | |
| // NodeTypeMap — the global registry | |
| // In the real codebase this uses declaration merging. | |
| // Here we define it directly for the spike. | |
| // ============================================================ | |
| interface NodeTypeMap { | |
| "num/add": NumAddNode; | |
| "num/sub": NumSubNode; | |
| "str/upper": StrUpperNode; | |
| } | |
| // ============================================================ | |
| // Core type machinery | |
| // ============================================================ | |
| type FoldYield = TypedNode; | |
| type IsAny<T> = 0 extends 1 & T ? true : false; | |
| type ExtractNodeParam<F> = F extends (node: infer N, ...args: any[]) => any ? N : unknown; | |
| type RejectAnyParam<_K extends string, H> = IsAny<ExtractNodeParam<H>> extends true ? never : H; | |
| type Handler<N extends TypedNode<any>> = N extends TypedNode<infer T> | |
| ? (node: N) => AsyncGenerator<FoldYield, T, unknown> | |
| : never; | |
| /** Raw handler map — the structural shape, without the brand. */ | |
| type InterpreterHandlers<K extends string> = { | |
| [key in K]: key extends keyof NodeTypeMap ? Handler<NodeTypeMap[key]> : never; | |
| }; | |
| // ============================================================ | |
| // Branded Interpreter — can ONLY be produced by factory functions | |
| // ============================================================ | |
| declare const interpreterBrand: unique symbol; | |
| /** | |
| * Branded interpreter type. The unique symbol makes it impossible to | |
| * construct via annotation — you must go through a factory function | |
| * (defineInterpreter or definePlugin) which rejects any-typed handlers. | |
| */ | |
| type Interpreter<K extends string> = InterpreterHandlers<K> & { | |
| readonly [interpreterBrand]: K; | |
| }; | |
| /** | |
| * Factory: create a branded Interpreter with any-rejection. | |
| * This is the ONLY way to produce an Interpreter<K>. | |
| */ | |
| function defineInterpreter<K extends string>() { | |
| return <T extends InterpreterHandlers<K>>( | |
| handlers: T & { | |
| [P in K]: P extends keyof T ? RejectAnyParam<P, T[P]> : never; | |
| }, | |
| ): Interpreter<K> => { | |
| return handlers as unknown as Interpreter<K>; // one trusted cast | |
| }; | |
| } | |
| // ============================================================ | |
| // PluginDefinition — K is the literal union of node kinds | |
| // ============================================================ | |
| interface PluginDefinition<K extends string = string, T = unknown> { | |
| name: string; | |
| nodeKinds: K[]; | |
| defaultInterpreter: Interpreter<K>; | |
| build: () => T; | |
| } | |
| /** | |
| * Factory: create a PluginDefinition with branded interpreter. | |
| * Infers K from nodeKinds, rejects any in handlers. | |
| */ | |
| function definePlugin<const Kinds extends string[], I extends InterpreterHandlers<Kinds[number]>, T>(def: { | |
| name: string; | |
| nodeKinds: Kinds; | |
| defaultInterpreter: I & { | |
| [P in Kinds[number]]: P extends keyof I ? RejectAnyParam<P, I[P]> : never; | |
| }; | |
| build: () => T; | |
| }): PluginDefinition<Kinds[number], T> { | |
| return { | |
| ...def, | |
| defaultInterpreter: def.defaultInterpreter as unknown as Interpreter<Kinds[number]>, | |
| } as PluginDefinition<Kinds[number], T>; | |
| } | |
| // ============================================================ | |
| // Program carries phantom K | |
| // ============================================================ | |
| interface Program<K extends string = string> { | |
| ast: TypedNode; | |
| hash: string; | |
| readonly __kinds?: K; | |
| } | |
| // ============================================================ | |
| // mvfm() infers K from plugins | |
| // ============================================================ | |
| type ExtractKinds<P> = P extends PluginDefinition<infer K, any> ? K : never; | |
| function mvfm<P extends PluginDefinition<any, any>[]>( | |
| ...plugins: P | |
| ): (builder: ($: any) => any) => Program<ExtractKinds<P[number]>> { | |
| void plugins; | |
| return (_builder) => ({ ast: { kind: "dummy" }, hash: "dummy" }) as any; | |
| } | |
| // ============================================================ | |
| // defaults() returns branded Interpreter<K> | |
| // ============================================================ | |
| type AppFn<K extends string> = (builder: ($: any) => any) => Program<K>; | |
| function defaults<K extends string>(_app: AppFn<K>): Interpreter<K> { | |
| return {} as Interpreter<K>; // merges plugin defaultInterpreters internally | |
| } | |
| // ============================================================ | |
| // foldAST — requires branded Interpreter<K>. No RejectAnyParam | |
| // needed here because the brand guarantees the interpreter was | |
| // produced by a factory that already rejected any. | |
| // ============================================================ | |
| async function foldAST<K extends string>( | |
| interpreter: Interpreter<K>, | |
| program: Program<K>, | |
| ): Promise<unknown> { | |
| void interpreter; | |
| void program; | |
| return undefined; | |
| } | |
| // ============================================================ | |
| // Merging interpreters (spread composition) | |
| // ============================================================ | |
| function mergeInterpreters<A extends string, B extends string>( | |
| a: Interpreter<A>, | |
| b: Interpreter<B>, | |
| ): Interpreter<A | B> { | |
| return { ...a, ...b } as Interpreter<A | B>; // trusted cast | |
| } | |
| // ============================================================ | |
| // Plugin definitions | |
| // ============================================================ | |
| const num = definePlugin({ | |
| name: "num", | |
| nodeKinds: ["num/add", "num/sub"], | |
| defaultInterpreter: { | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/add": async function* (node: NumAddNode) { | |
| return node.left as unknown as number; | |
| }, | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/sub": async function* (node: NumSubNode) { | |
| return node.left as unknown as number; | |
| }, | |
| }, | |
| build: () => ({ add: null, sub: null }), | |
| }); | |
| const str = definePlugin({ | |
| name: "str", | |
| nodeKinds: ["str/upper"], | |
| defaultInterpreter: { | |
| // biome-ignore lint/correctness/useYield: spike | |
| "str/upper": async function* (_node: StrUpperNode) { | |
| return ""; | |
| }, | |
| }, | |
| build: () => ({ upper: null }), | |
| }); | |
| // ============================================================ | |
| // Also works: defineInterpreter standalone | |
| // ============================================================ | |
| const _numInterp = defineInterpreter<"num/add" | "num/sub">()({ | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/add": async function* (node: NumAddNode) { | |
| return node.left as unknown as number; | |
| }, | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/sub": async function* (node: NumSubNode) { | |
| return node.left as unknown as number; | |
| }, | |
| }); | |
| // ============================================================ | |
| // POSITIVE TESTS — these must compile | |
| // ============================================================ | |
| // Multi-plugin: K = "num/add" | "num/sub" | "str/upper" | |
| const app = mvfm(num, str); | |
| const prog = app(($) => $.add(1, 2)); | |
| const interp = defaults(app); | |
| void foldAST(interp, prog); | |
| // Single plugin | |
| const numApp = mvfm(num); | |
| const numProg = numApp(($) => $.add(1, 2)); | |
| const numInterp = defaults(numApp); | |
| void foldAST(numInterp, numProg); | |
| // Merge interpreters | |
| const merged = mergeInterpreters( | |
| defineInterpreter<"num/add" | "num/sub">()({ | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/add": async function* (node: NumAddNode) { | |
| return node.left as unknown as number; | |
| }, | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/sub": async function* (node: NumSubNode) { | |
| return node.left as unknown as number; | |
| }, | |
| }), | |
| defineInterpreter<"str/upper">()({ | |
| // biome-ignore lint/correctness/useYield: spike | |
| "str/upper": async function* (_node: StrUpperNode) { | |
| return ""; | |
| }, | |
| }), | |
| ); | |
| void foldAST(merged, prog); | |
| // ============================================================ | |
| // NEGATIVE TESTS — these must NOT compile | |
| // ============================================================ | |
| // --- Brand prevents constructing Interpreter via annotation --- | |
| // @ts-expect-error brand prevents raw construction — missing [interpreterBrand] | |
| const _brandBlocked: Interpreter<"num/add"> = { | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/add": async function* (node: NumAddNode) { | |
| return node.left as unknown as number; | |
| }, | |
| }; | |
| // --- Brand prevents ANY handler via annotation (even correct types) --- | |
| // @ts-expect-error brand prevents raw construction even with correct handler types | |
| const _brandBlockedCorrectTypes: Interpreter<"num/add" | "num/sub"> = { | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/add": async function* (node: NumAddNode) { | |
| return node.left as unknown as number; | |
| }, | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/sub": async function* (node: NumSubNode) { | |
| return node.left as unknown as number; | |
| }, | |
| }; | |
| // --- node:any rejected in defineInterpreter --- | |
| const _badAny = defineInterpreter<"num/add">()({ | |
| // @ts-expect-error node:any rejected by RejectAnyParam | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/add": async function* (node: any) { | |
| return node; | |
| }, | |
| }); | |
| // --- wrong node type rejected in defineInterpreter --- | |
| const _badWrongType = defineInterpreter<"num/add">()({ | |
| // @ts-expect-error wrong node type | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/add": async function* (node: StrUpperNode) { | |
| return node.inner; | |
| }, | |
| }); | |
| // --- missing handler rejected in defineInterpreter --- | |
| // @ts-expect-error missing "num/sub" handler | |
| const _badMissing = defineInterpreter<"num/add" | "num/sub">()({ | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/add": async function* (node: NumAddNode) { | |
| return node.left as unknown as number; | |
| }, | |
| }); | |
| // --- node:any rejected in definePlugin --- | |
| const _badPlugin = definePlugin({ | |
| name: "bad", | |
| nodeKinds: ["num/add"], | |
| defaultInterpreter: { | |
| // @ts-expect-error node:any rejected in definePlugin | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/add": async function* (node: any) { | |
| return node; | |
| }, | |
| }, | |
| build: () => ({ add: null }), | |
| }); | |
| // --- unregistered kind produces never in definePlugin --- | |
| const _unregPlugin = definePlugin({ | |
| name: "unreg", | |
| nodeKinds: ["fake/kind"], | |
| defaultInterpreter: { | |
| // @ts-expect-error unregistered kind maps to never | |
| // biome-ignore lint/correctness/useYield: spike | |
| "fake/kind": async function* (_node: TypedNode) { | |
| return undefined; | |
| }, | |
| }, | |
| build: () => ({}), | |
| }); | |
| // --- foldAST rejects unbranded interpreter --- | |
| void foldAST( | |
| // @ts-expect-error unbranded object rejected by foldAST | |
| { | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/add": async function* (node: NumAddNode) { | |
| return node.left as unknown as number; | |
| }, | |
| // biome-ignore lint/correctness/useYield: spike | |
| "num/sub": async function* (node: NumSubNode) { | |
| return node.left as unknown as number; | |
| }, | |
| }, | |
| numProg, | |
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Show hidden characters
| { | |
| "compilerOptions": { | |
| "strict": true, | |
| "noEmit": true, | |
| "target": "ES2022", | |
| "lib": ["ES2022"], | |
| "module": "ES2022", | |
| "moduleResolution": "bundler" | |
| }, | |
| "include": ["spike.ts"] | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment