From b31aa383172664e60dad59e614ca60c4f41ef42a Mon Sep 17 00:00:00 2001 From: MartianGreed Date: Tue, 23 Sep 2025 10:08:15 +0200 Subject: [PATCH] feat(types): extend ABI tooling for schema generation --- .changeset/mighty-feet-move.md | 5 + packages/core/src/cli/compile-abi.ts | 227 +++++++++++++++++++---- packages/core/src/types/README.md | 136 +++++--------- packages/core/src/types/example-usage.ts | 29 +-- packages/core/src/types/index.test.ts | 120 ++++++++++++ packages/core/src/types/index.ts | 108 +++++++++++ 6 files changed, 489 insertions(+), 136 deletions(-) create mode 100644 .changeset/mighty-feet-move.md create mode 100644 packages/core/src/types/index.test.ts diff --git a/.changeset/mighty-feet-move.md b/.changeset/mighty-feet-move.md new file mode 100644 index 00000000..66ef3472 --- /dev/null +++ b/.changeset/mighty-feet-move.md @@ -0,0 +1,5 @@ +--- +"@dojoengine/core": patch +--- + +feat(core): add ABI tooling for schema generation diff --git a/packages/core/src/cli/compile-abi.ts b/packages/core/src/cli/compile-abi.ts index ee6203ee..9715ee69 100644 --- a/packages/core/src/cli/compile-abi.ts +++ b/packages/core/src/cli/compile-abi.ts @@ -1,7 +1,15 @@ #!/usr/bin/env node -import { readFileSync, writeFileSync, readdirSync, existsSync } from "fs"; -import { join } from "path"; +import { + readFileSync, + writeFileSync, + readdirSync, + existsSync, + mkdirSync, +} from "fs"; +import { join, isAbsolute, dirname, resolve } from "path"; +import { fileURLToPath } from "url"; +import type { Dirent } from "fs"; type AbiEntry = { [key: string]: any; @@ -26,13 +34,41 @@ type TargetFile = { [key: string]: any; }; +type OutputPaths = { + json: string; + ts: string; +}; + +type CollectOptions = { + generateTypes: boolean; + outputPath?: string; +}; + /** * Generate a TypeScript file from compiled-abi.json with proper const assertions * This allows TypeScript to extract literal types from the ABI */ -function generateAbiTypes(dojoRoot: string): void { - const inputPath = join(dojoRoot, "compiled-abi.json"); - const outputPath = join(dojoRoot, "compiled-abi.ts"); +function ensureDirectory(path: string): void { + const directory = dirname(path); + mkdirSync(directory, { recursive: true }); +} + +export function resolveOutputPaths(outputOption?: string): OutputPaths { + const jsonPath = outputOption + ? isAbsolute(outputOption) + ? outputOption + : join(process.cwd(), outputOption) + : join(process.cwd(), "compiled-abi.json"); + + const tsPath = jsonPath.endsWith(".json") + ? `${jsonPath.slice(0, -5)}.ts` + : `${jsonPath}.ts`; + + return { json: jsonPath, ts: tsPath }; +} + +function generateAbiTypes(paths: OutputPaths): void { + const { json: inputPath, ts: outputPath } = paths; try { // Read the compiled ABI @@ -49,14 +85,18 @@ export type CompiledAbi = typeof compiledAbi; `; // Write the TypeScript file + ensureDirectory(outputPath); writeFileSync(outputPath, tsContent); console.log(`✅ Generated TypeScript types!`); - console.log(`📄 Output written to: ${outputPath}`); - console.log(`\nUsage in your code:`); - console.log(`\nimport { compiledAbi } from './compiled-abi';`); + console.log(`📄 Type output written to: ${outputPath}`); + console.log(` +Usage in your code:`); + console.log(` +import { compiledAbi } from './compiled-abi';`); console.log(`import { ExtractAbiTypes } from '@dojoengine/core';`); - console.log(`\ntype MyAbi = ExtractAbiTypes;`); + console.log(` +type MyAbi = ExtractAbiTypes;`); console.log( `type Position = MyAbi["structs"]["dojo_starter::models::Position"];` ); @@ -66,7 +106,27 @@ export type CompiledAbi = typeof compiledAbi; } } -function collectAbis(generateTypes: boolean): void { +function walkJsonFiles(root: string, entries: Dirent[] = []): string[] { + const collected: string[] = []; + + for (const entry of entries) { + const fullPath = join(root, entry.name); + + if (entry.isDirectory()) { + const childEntries = readdirSync(fullPath, { withFileTypes: true }); + collected.push(...walkJsonFiles(fullPath, childEntries)); + continue; + } + + if (entry.isFile() && entry.name.endsWith(".json")) { + collected.push(fullPath); + } + } + + return collected; +} + +function collectAbis(options: CollectOptions): void { const dojoRoot = process.env.DOJO_ROOT || process.cwd(); const dojoEnv = process.env.DOJO_ENV || "dev"; @@ -74,6 +134,7 @@ function collectAbis(generateTypes: boolean): void { const targetDir = join(dojoRoot, "target", dojoEnv); const allAbis: AbiEntry[] = []; + let manifest: Manifest | null = null; // Read manifest file if (!existsSync(manifestPath)) { @@ -83,7 +144,7 @@ function collectAbis(generateTypes: boolean): void { try { const manifestContent = readFileSync(manifestPath, "utf-8"); - const manifest: Manifest = JSON.parse(manifestContent); + manifest = JSON.parse(manifestContent) as Manifest; // Extract ABIs from world if (manifest.world?.abi) { @@ -108,12 +169,10 @@ function collectAbis(generateTypes: boolean): void { console.warn(`Target directory not found: ${targetDir}`); } else { try { - const files = readdirSync(targetDir).filter((file) => - file.endsWith(".json") - ); + const dirEntries = readdirSync(targetDir, { withFileTypes: true }); + const files = walkJsonFiles(targetDir, dirEntries); - for (const file of files) { - const filePath = join(targetDir, file); + for (const filePath of files) { try { const fileContent = readFileSync(filePath, "utf-8"); const targetFile: TargetFile = JSON.parse(fileContent); @@ -123,7 +182,7 @@ function collectAbis(generateTypes: boolean): void { allAbis.push(...targetFile.abi); } } catch (error) { - console.error(`Error reading file ${file}: ${error}`); + console.error(`Error reading file ${filePath}: ${error}`); } } } catch (error) { @@ -131,32 +190,134 @@ function collectAbis(generateTypes: boolean): void { } } + const dedupedAbis = new Map(); + const duplicateCounts: Record = {}; + + for (const entry of allAbis) { + const type = typeof entry.type === "string" ? entry.type : "unknown"; + const name = + typeof (entry as { name?: string }).name === "string" + ? (entry as { name: string }).name + : ""; + const interfaceName = + typeof (entry as { interface_name?: string }).interface_name === + "string" + ? (entry as { interface_name: string }).interface_name + : ""; + + const key = `${type}::${name}::${interfaceName}`; + + if (dedupedAbis.has(key)) { + duplicateCounts[key] = (duplicateCounts[key] ?? 1) + 1; + continue; + } + + dedupedAbis.set(key, entry); + } + + const mergedAbis = Array.from(dedupedAbis.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([, value]) => value); + + if (Object.keys(duplicateCounts).length > 0) { + console.warn("! Duplicate ABI entries detected and ignored:"); + for (const [key, count] of Object.entries(duplicateCounts)) { + console.warn(` • ${key} (${count} occurrences)`); + } + } + // Write output const output = { - abi: allAbis, + abi: mergedAbis, + manifest: manifest && { + world: manifest.world, + base: manifest.base, + contracts: manifest.contracts ?? [], + models: manifest.models ?? [], + }, }; - const outputPath = join(dojoRoot, "compiled-abi.json"); - writeFileSync(outputPath, JSON.stringify(output, null, 2)); + const paths = resolveOutputPaths(options.outputPath); + ensureDirectory(paths.json); + writeFileSync(paths.json, JSON.stringify(output, null, 2)); console.log(`✅ ABI compilation complete!`); - console.log(`📄 Output written to: ${outputPath}`); - console.log(`📊 Total ABI entries: ${allAbis.length}`); + console.log(`📄 Output written to: ${paths.json}`); + console.log(`📊 Total ABI entries: ${mergedAbis.length}`); + + const typeStats = mergedAbis.reduce>((acc, item) => { + const key = typeof item.type === "string" ? item.type : "unknown"; + acc[key] = (acc[key] ?? 0) + 1; + return acc; + }, {}); + + for (const [abiType, count] of Object.entries(typeStats)) { + console.log(` • ${abiType}: ${count}`); + } // Generate TypeScript types if requested - if (generateTypes) { - generateAbiTypes(dojoRoot); + if (options.generateTypes) { + generateAbiTypes(paths); + } +} + +function parseArgs(argv: string[]): CollectOptions { + let generateTypes = false; + let outputPath: string | undefined; + let index = 0; + + while (index < argv.length) { + const arg = argv[index]; + + if (arg === "--generate-types") { + generateTypes = true; + index += 1; + continue; + } + + if (arg === "--output") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + console.error("Missing value for --output option"); + process.exit(1); + } + outputPath = value; + index += 2; + continue; + } + + if (arg.startsWith("--output=")) { + const value = arg.slice("--output=".length); + if (!value) { + console.error("Missing value for --output option"); + process.exit(1); + } + outputPath = value; + index += 1; + continue; + } + + console.warn(`! Unknown argument ignored: ${arg}`); + index += 1; } + + return { + generateTypes, + outputPath, + }; } -// Parse command line arguments -const args = process.argv.slice(2); -const generateTypes = args.includes("--generate-types"); +const __filename = resolve(fileURLToPath(import.meta.url)); +const entryPoint = process.argv[1] ? resolve(process.argv[1]) : undefined; +const isDirectExecution = entryPoint === __filename; + +if (isDirectExecution) { + const options = parseArgs(process.argv.slice(2)); -// Run the compilation -try { - collectAbis(generateTypes); -} catch (error) { - console.error(`Unexpected error: ${error}`); - process.exit(1); + try { + collectAbis(options); + } catch (error) { + console.error(`Unexpected error: ${error}`); + process.exit(1); + } } diff --git a/packages/core/src/types/README.md b/packages/core/src/types/README.md index 8311c1a7..15a4a39e 100644 --- a/packages/core/src/types/README.md +++ b/packages/core/src/types/README.md @@ -1,115 +1,67 @@ -# Dynamic Type System for Dojo Manifests +# Dynamic Type System for Dojo ABIs -This module provides a powerful TypeScript type system that dynamically extracts types from Dojo manifest files, providing full type safety when working with contracts, models, and events. +This module turns a Dojo world's ABI into rich TypeScript types that can be used across the SDK. After aggregating your world's JSON ABIs into a single `compiled-abi.json`, you can generate a TypeScript file with literal-preserving `const` data and derive types directly from it. -## Features +## Generate the aggregated ABI -- **Automatic Type Extraction**: Extracts types from manifest JSON files -- **Cairo to TypeScript Mapping**: Automatically converts Cairo types to TypeScript equivalents -- **Full ABI Support**: Extracts structs, enums, functions, and events from ABIs -- **Type-Safe Contract Calls**: Get typed function inputs and outputs -- **Model Type Generation**: Generate TypeScript interfaces for your Dojo models +Run the bundled CLI from the root of your Dojo world: -## Usage - -### Basic Setup - -```typescript -import { ExtractManifestTypes } from "@dojoengine/core"; -import manifest from "./manifest_dev.json"; - -// Extract all types from the manifest -type MyTypes = ExtractManifestTypes; +```bash +npx @dojoengine/core compile-abi --generate-types ``` -### Working with Contracts +Need a custom location? Append `--output path/to/compiled-abi.json` (relative paths are resolved from your Dojo root). The TypeScript file will be emitted alongside the JSON with a `.ts` extension. -```typescript -// Access contract types -type Contracts = MyTypes["contracts"]; -type ActionsContract = Contracts["dojo_starter-actions"]; +The command collects every ABI entry from `manifest_.json` and `target//**/*.json`, deduplicates them, writes a consolidated `compiled-abi.json`, and emits a matching `compiled-abi.ts` that exports the ABI with `as const`. -// Get function types -type MoveFunction = ActionsContract["abi"]["functions"]["move"]; -type MoveInputs = MoveFunction["inputs"]; // { direction: Direction } -type MoveOutput = MoveFunction["outputs"]; // void +## Getting started in TypeScript -// Use in your code -function callMove(inputs: MoveInputs): MoveOutput { - // Type-safe contract call -} -``` +```ts +import { compiledAbi } from "./compiled-abi"; +import { + ExtractAbiTypes, + ModelsFromAbi, + ModelPathFromAbi, + GetModel, + GetActionFunction, +} from "@dojoengine/core"; -### Working with Models +// Primary entry point – exposes structs, enums, interfaces, models, and actions +type Abi = ExtractAbiTypes; -```typescript -// Access model types -type Models = MyTypes["models"]; -type Position = Models["dojo_starter-Position"]; -type PositionFields = Position["fields"]; +// Schema of Dojo models keyed by namespace → model name +type Schema = ModelsFromAbi; -// Or use the helper -import { GetModel } from "@dojoengine/core"; -type PositionData = GetModel; -``` - -### Working with Events +type Position = GetModel; -```typescript -// Access event types -type Events = MyTypes["events"]; -type MovedEvent = Events["dojo_starter-Moved"]; -type MovedEventData = MovedEvent["data"]; +type Move = GetActionFunction< + typeof compiledAbi, + "dojo_starter", + "IActions", + "move" +>; -// Type-safe event handler -function handleMoved(data: MovedEventData) { - // Handle event with typed data -} +// Use strongly typed model paths with torii query builders +type ModelPath = ModelPathFromAbi; // e.g. "dojo_starter-Position" ``` -## Type Mappings +## Extracted type groups -The system automatically maps Cairo types to TypeScript: +- **`structs`** – Cairo structs mapped to TypeScript objects with nested references resolved. +- **`enums`** – Cairo enums with both the variant union (`type`) and a `variants` object. +- **`interfaces`** – Contract interfaces keyed by fully qualified name with typed function signatures. +- **`models`** – Dojo models grouped by namespace; the shape aligns with `SchemaType` used throughout the SDK. +- **`actions`** – System action interfaces grouped by namespace, preserving typed inputs and outputs. -| Cairo Type | TypeScript Type | -| --------------------------------------------------- | --------------- | -| `core::felt252` | `string` | -| `core::integer::u8` | `number` | -| `core::integer::u16` | `number` | -| `core::integer::u32` | `number` | -| `core::integer::u64` | `bigint` | -| `core::integer::u128` | `bigint` | -| `core::bool` | `boolean` | -| `core::starknet::contract_address::ContractAddress` | `string` | -| `core::byte_array::ByteArray` | `string` | -| `core::array::Array` | `T[]` | -| `core::array::Span` | `T[]` | +```ts +type Direction = Abi["enums"]["dojo_starter::models::Direction"]["type"]; // "Up" | "Down" | ... -## Advanced Usage - -### Extract Specific Function Type - -```typescript -import { GetContractFunction } from "@dojoengine/core"; - -type SpawnFunction = GetContractFunction< - typeof manifest, - "dojo_starter-actions", - "spawn" ->; +type Actions = Abi["actions"]["dojo_starter"]["IActions"]; +type SpawnInputs = Actions["spawn"]["inputs"]; // { entity: Position; ... } ``` -### Working with Enums - -```typescript -// Access enum types from ABI -type Direction = - ActionsContract["abi"]["enums"]["dojo_starter::models::Direction"]; - -// Use in your code -const direction: Direction = { Left: {} }; -``` +## From types to runtime helpers -## Example +All helpers operate purely on types, so the generated `compiled-abi.ts` can be tree-shaken in applications that only need compile-time support. At runtime you can still import the JSON version alongside it if required. -See `example-usage.ts` for a complete example of using the type system with a real manifest file. +The derived schema satisfies the `SchemaType` consumed by internal utilities, allowing seamless wiring with query builders and the SDK's entity helpers without manually writing model definitions. diff --git a/packages/core/src/types/example-usage.ts b/packages/core/src/types/example-usage.ts index 3670d55b..d18df207 100644 --- a/packages/core/src/types/example-usage.ts +++ b/packages/core/src/types/example-usage.ts @@ -1,13 +1,17 @@ -import { ExtractAbiTypes } from "./index"; +import { + ExtractAbiTypes, + ModelsFromAbi, + GetModel, + GetActionFunction, +} from "./index"; // Solution 1: Import the generated TypeScript file instead -import { - CompiledAbi, - compiledAbi, -} from "../../../../worlds/dojo-starter/compiled-abi"; +// import { compiledAbi } from "../../../../worlds/dojo-starter/compiled-abi"; +import { compiledAbi } from "./nums_dev"; // Extract ABI types from the TypeScript version (this works!) type MyAbi = ExtractAbiTypes; +type Schema = ModelsFromAbi; // Note: If you need the JSON at runtime, you can still import it separately // The types come from the TypeScript file, the runtime data from JSON @@ -31,9 +35,9 @@ type MyAbiFunctions = MyAbi["functions"]; type MyAbiInterfaces = MyAbi["interfaces"]; // Now you can use the extracted types -type Vec2 = MyAbi["structs"]["dojo_starter::models::Vec2"]; // { x: number; y: number } -type Position = MyAbi["structs"]["dojo_starter::models::Position"]; // { player: string; vec: Vec2 } -type PositionCount = MyAbi["structs"]["dojo_starter::models::PositionCount"]; +type Vec2 = Schema["dojo_starter"]["Vec2"]; +type Position = GetModel; +type PositionCount = Schema["dojo_starter"]["PositionCount"]; // Note: Nested struct references are resolved through the ABI context. // The type system now supports cross-references between structs in the same ABI. @@ -53,9 +57,12 @@ type WorldResourceFunction = IWorld["resource"]; // { inputs: { selector: string type WorldUuidFunction = IWorld["uuid"]; // { inputs: {}, outputs: number } // Action interface example -type IActions = MyAbi["interfaces"]["dojo_starter::systems::actions::IActions"]; -type SpawnFunction = IActions["spawn"]; // { inputs: {}, outputs: void } -type MoveFunction = IActions["move"]; // { inputs: { direction: Direction["type"] }, outputs: void } +type MoveFunction = GetActionFunction< + typeof compiledAbi, + "dojo_starter", + "IActions", + "move" +>; // { inputs: { direction: Direction["type"] }, outputs: void } // To make this work with your actual compiled-abi.json, you need to: // 1. Create a script that converts compiled-abi.json to a TypeScript file with proper const assertions diff --git a/packages/core/src/types/index.test.ts b/packages/core/src/types/index.test.ts new file mode 100644 index 00000000..94ad1d52 --- /dev/null +++ b/packages/core/src/types/index.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { + ExtractAbiTypes, + ModelPathFromAbi, + GetModel, + ModelsFromAbi, + ActionsFromAbi, + GetActionFunction, +} from "./index"; + +const sampleAbi = { + abi: [ + { + type: "struct", + name: "demo::models::Player", + members: [ + { name: "id", type: "core::felt252" }, + { name: "score", type: "core::integer::u32" }, + ], + }, + { + type: "struct", + name: "demo::models::Position", + members: [ + { name: "player", type: "demo::models::Player" }, + { name: "x", type: "core::integer::u16" }, + { name: "y", type: "core::integer::u16" }, + ], + }, + { + type: "enum", + name: "demo::models::Direction", + variants: [ + { name: "Up", type: "()" }, + { name: "Down", type: "()" }, + { name: "Left", type: "()" }, + { name: "Right", type: "()" }, + ], + }, + { + type: "function", + name: "move", + inputs: [ + { name: "entity", type: "demo::models::Position" }, + { name: "direction", type: "demo::models::Direction" }, + ], + outputs: [{ type: "()" }], + }, + { + type: "interface", + name: "demo::systems::actions::IActions", + items: [ + { + type: "function", + name: "move", + inputs: [ + { name: "entity", type: "demo::models::Position" }, + { name: "direction", type: "demo::models::Direction" }, + ], + outputs: [{ type: "()" }], + state_mutability: "external", + }, + ], + }, + ], +} as const; + +type Extracted = ExtractAbiTypes; + +describe("ExtractAbiTypes", () => { + it("maps Cairo structs to TypeScript models", () => { + expectTypeOf< + Extracted["structs"]["demo::models::Player"] + >().toEqualTypeOf<{ + id: string; + score: number; + }>(); + + expectTypeOf().toEqualTypeOf<{ + player: Extracted["models"]["demo"]["Player"]; + x: number; + y: number; + }>(); + }); + + it("provides model paths and lookups", () => { + type Paths = ModelPathFromAbi; + expectTypeOf().toEqualTypeOf<"demo-Player" | "demo-Position">(); + + expectTypeOf< + GetModel + >().toEqualTypeOf<{ + id: string; + score: number; + }>(); + }); + + it("exposes enums and actions with typed members", () => { + expectTypeOf< + Extracted["enums"]["demo::models::Direction"]["type"] + >().toEqualTypeOf<"Up" | "Down" | "Left" | "Right">(); + + type Actions = ActionsFromAbi; + expectTypeOf().toEqualTypeOf<"demo">(); + expectTypeOf().toEqualTypeOf<"IActions">(); + + type Move = GetActionFunction< + typeof sampleAbi, + "demo", + "IActions", + "move" + >; + + expectTypeOf().toEqualTypeOf<{ + entity: ModelsFromAbi["demo"]["Position"]; + direction: "Up" | "Down" | "Left" | "Right"; + }>(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 02d221d6..49ac9cc0 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -232,6 +232,16 @@ type MapTupleTypes< ? [] : [MapCairoType]; +type Simplify = T extends object ? { [K in keyof T]: T[K] } : T; + +type UnionToIntersection = ( + U extends any + ? (arg: U) => void + : never +) extends (arg: infer I) => void + ? I + : never; + // ======================== // ABI Type Extraction // ======================== @@ -282,6 +292,8 @@ export type ExtractAbiTypesFromArray = ABI extends readonly any[] enums: ExtractEnums; functions: ExtractFunctions; interfaces: ExtractInterfaces; + models: ExtractModels; + actions: ExtractActions>; } : never; @@ -435,6 +447,52 @@ type ExtractInterfaces = { : never; }; +type ModelStructNames = Extract< + ExtractStructNames, + `${string}::models::${string}` +>; + +type MergeModelEntries = UnionToIntersection< + ModelStructNames extends infer Name + ? Name extends `${infer Namespace}::models::${infer Model}` + ? { + [K in Namespace]: { + [P in Model]: ExtractStructType; + }; + } + : {} + : {} +>; + +type ExtractModels = Simplify< + MergeModelEntries +>; + +type ActionInterfaceNames = Extract< + ExtractInterfaceNames, + `${string}::systems::actions::${string}` +>; + +type MergeActionEntries< + ABI extends readonly any[], + Interfaces extends Record, +> = UnionToIntersection< + ActionInterfaceNames extends infer Name + ? Name extends `${infer Namespace}::systems::actions::${infer Interface}` + ? { + [K in Namespace]: { + [P in Interface]: Interfaces[Name & keyof Interfaces]; + }; + } + : {} + : {} +>; + +type ExtractActions< + ABI extends readonly any[], + Interfaces extends Record, +> = Simplify>; + /** * Main exported type for extracting ABI types * Usage: @@ -448,3 +506,53 @@ export type ExtractAbiTypes = T extends { abi: infer ABI } : T extends readonly any[] ? ExtractAbiTypesFromArray : never; + +type ModelCollection = ExtractAbiTypes["models"]; + +type ActionCollection = ExtractAbiTypes["actions"]; + +export type ModelsFromAbi = ModelCollection; + +export type ActionsFromAbi = ActionCollection; + +type ModelPathUnion = Models extends Record> + ? { + [Namespace in keyof Models & string]: { + [Model in keyof Models[Namespace] & + string]: `${Namespace}-${Model}`; + }[keyof Models[Namespace] & string]; + }[keyof Models & string] + : never; + +export type ModelPathFromAbi = ModelPathUnion>; + +export type GetModel< + T, + Path extends ModelPathFromAbi, +> = ModelCollection extends Record> + ? Path extends `${infer Namespace}-${infer Model}` + ? Namespace extends keyof ModelCollection + ? Model extends keyof ModelCollection[Namespace] + ? ModelCollection[Namespace][Model] + : never + : never + : never + : never; + +export type GetActions< + T, + Namespace extends keyof ActionCollection, +> = ActionCollection[Namespace]; + +export type GetActionInterface< + T, + Namespace extends keyof ActionCollection, + InterfaceName extends keyof ActionCollection[Namespace], +> = ActionCollection[Namespace][InterfaceName]; + +export type GetActionFunction< + T, + Namespace extends keyof ActionCollection, + InterfaceName extends keyof ActionCollection[Namespace], + FunctionName extends keyof ActionCollection[Namespace][InterfaceName], +> = ActionCollection[Namespace][InterfaceName][FunctionName];