Repository: total-typescript/untypeable Branch: main Commit: 95e069702995 Files: 28 Total size: 42.4 KB Directory structure: gitextract_qcn3yl_o/ ├── .changeset/ │ ├── README.md │ └── config.json ├── .github/ │ └── workflows/ │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── docs/ │ └── swapi-example/ │ ├── swapi-example.ts │ └── types.ts ├── package.json ├── readme.md ├── src/ │ ├── client.ts │ ├── constants.ts │ ├── index.ts │ ├── playground.ts │ ├── safe-client.ts │ ├── tests/ │ │ ├── add.test.ts │ │ ├── dynamic-args.test.ts │ │ ├── merge.test.ts │ │ ├── repl.test.ts │ │ ├── router-definition.test.ts │ │ ├── type-only.test.ts │ │ ├── utils.ts │ │ └── with-schemas.test.ts │ ├── types.ts │ └── untypeable.ts ├── tsconfig.json └── tsup.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .changeset/README.md ================================================ # Changesets Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in our repository](https://github.com/changesets/changesets) We have a quick list of common questions to get you started engaging with this project in [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) ================================================ FILE: .changeset/config.json ================================================ { "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [] } ================================================ FILE: .github/workflows/main.yml ================================================ name: CI on: push: branches: - "**" pull_request: branches: - "**" jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: pnpm/action-setup@v2 with: version: 7 - uses: actions/setup-node@v3 with: node-version: 16.x cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run ci ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish on: push: branches: - "main" concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: pnpm/action-setup@v2 with: version: 7 - uses: actions/setup-node@v3 with: node-version: 16.x cache: "pnpm" - run: pnpm install --frozen-lockfile - name: Create Release Pull Request or Publish id: changesets uses: changesets/action@v1 with: publish: pnpm run release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .gitignore ================================================ node_modules dist ================================================ FILE: .npmignore ================================================ src node_modules pnpm-lock.yaml tsconfig.json ================================================ FILE: CHANGELOG.md ================================================ # untypeable ## 0.2.0 ### Minor Changes - 9db7f9b: Made it so you don't need to pass an empty object to the client if the input is entirely partial. ## 0.1.0 ### Minor Changes - 0441952: Initial release of untypeable - check the readme for details! ================================================ FILE: docs/swapi-example/swapi-example.ts ================================================ import { createTypeLevelClient, initUntypeable } from "../../src/index"; import { Person, Paginated, Planet, Film, Vehicle } from "./types"; const u = initUntypeable(); /** * Create the router */ const router = u.router({ "/people/:id": u.input<{ id: string }>().output(), "/people": u.output>(), "/planets/:id": u.input<{ id: string }>().output(), "/planets": u.output>(), "/films/:id": u.input<{ id: string }>().output(), "/films": u.output>(), "/vehicles/:id": u.input<{ id: string }>().output(), "/vehicles": u.output>(), }); /** * Create the client, using the zero-bundle method */ export const fetchFromSwapi = createTypeLevelClient( (path, input = {}) => { // Replace dynamic path params in url const pathWithParams = path.replace( /:([a-zA-Z0-9_]+)/g, (_, key) => input[key], ); return fetch(`https://swapi.dev/api${pathWithParams}`, { method: "GET", headers: { "Content-Type": "application/json", }, }).then((res) => { if (!res.ok) { throw new Error(`HTTP error! status: ${res.status}`); } return res.json(); }); }, ); ================================================ FILE: docs/swapi-example/types.ts ================================================ export type Person = { name: string; height: string; mass: string; hair_color: string; skin_color: string; birth_year: string; gender: string; homeworld: string; films: string[]; }; export type Planet = { climate: string; diameter: string; gravity: string; name: string; orbital_period: string; population: string; residents: string[]; }; export type Film = { title: string; episode_id: number; opening_crawl: string; director: string; producer: string; release_date: string; characters: string[]; planets: string[]; starships: string[]; vehicles: string[]; }; export type Vehicle = { name: string; model: string; manufacturer: string; cost_in_credits: string; length: string; max_atmosphering_speed: string; }; export type Paginated = { count: number; next: string | null; previous: string | null; results: T[]; }; ================================================ FILE: package.json ================================================ { "name": "untypeable", "version": "0.2.1", "description": "Get type-safe access to any API, with a zero-bundle size option.", "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" }, "./client": { "types": "./dist/client.d.ts", "import": "./dist/client.mjs", "require": "./dist/client.js" }, "./package.json": "./package.json" }, "scripts": { "build": "tsup", "lint": "tsc", "ci": "npm run build && npm run lint && npm run test", "prepublish": "npm run ci", "test": "vitest run", "dev": "vitest", "release": "npm run ci && changeset publish" }, "keywords": [], "author": "Matt Pocock", "license": "ISC", "devDependencies": { "@changesets/cli": "^2.26.0", "tsup": "^6.6.3", "typescript": "^4.9.5", "vite": "^4.1.4", "vitest": "^0.29.2", "zod": "^3.21.4" } } ================================================ FILE: readme.md ================================================ # Untypeable Get type-safe access to any API, with a zero-bundle size option. ## The Problem If you're lucky enough to use [tRPC](https://trpc.io/), [GraphQL](https://graphql.org/), or [OpenAPI](https://www.openapis.org/), you'll be able to get **type-safe access to your API** - either through a type-safe RPC or codegen. But **what about the rest of us**? What do you do if **your API has no types**? ## Solution Enter `untypeable` - a first-class library for typing API's you don't control. - 🚀 Get **autocomplete on your entire API**, without needing to set up a single generic function. - 💪 **Simple to configure**, and extremely **flexible**. - 🤯 Choose between two modes: - **Zero bundle-size**: use `import type` to ensure `untypeable` adds nothing to your bundle. - **Strong types**: integrates with libraries like [Zod](https://zod.dev/) to add runtime safety to the types. - ✨ **Keep things organized** with helpers for merging and combining your config. - ❤️ You bring the fetcher, we bring the types. There's **no hidden magic**. ## Quickstart `npm i untypeable` ```ts import { initUntypeable, createTypeLevelClient } from "untypeable"; // Initialize untypeable const u = initUntypeable(); type User = { id: string; name: string; }; // Create a router // - Add typed inputs and outputs const router = u.router({ "/user": u.input<{ id: string }>().output(), }); const BASE_PATH = "http://localhost:3000"; // Create your client // - Pass any fetch implementation here const client = createTypeLevelClient((path, input) => { return fetch(BASE_PATH + path + `?${new URLSearchParams(input)}`).then( (res) => res.json(), ); }); // Type-safe data access! // - user is typed as User // - { id: string } must be passed as the input const user = await client("/user", { id: "1", }); ``` ## SWAPI Example We've added a [full example](./docs/swapi-example/swapi-example.ts) of typing `swapi.dev`. ## Zero-bundle mode You can set up `untypeable` to run in zero-bundle mode. This is great for situations where you trust the API you're calling, but it just doesn't have types. To set up zero-bundle mode, you'll need to: 1. Define your router in a file called `router.ts`. 2. Export the type of your router: `export type MyRouter = typeof router;` ```ts // router.ts import { initUntypeable } from "untypeable"; const u = initUntypeable(); type User = { id: string; name: string; }; const router = u.router({ "/user": u.input<{ id: string }>().output(), }); export type MyRouter = typeof router; ``` 3. In a file called `client.ts`, import `createTypeLevelClient` from `untypeable/type-level-client`. ```ts // client.ts import { createTypeLevelClient } from "untypeable/client"; import type { MyRouter } from "./router"; export const client = createTypeLevelClient(() => { // your implementation... }); ``` ### How does this work? This works because `createTypeLevelClient` is just an identity function, which directly returns the function you pass it. Most modern bundlers are smart enough to [collapse identity functions](https://github.com/evanw/esbuild/pull/1898) and erase type imports, so you end up with: ```ts // client.ts export const client = () => { // your implementation... }; ``` ## Runtime-safe mode Sometimes, you just don't trust the API you're calling. In those situations, you'll often like to _validate_ the data you get back. `untypeable` offers first-class integration with [Zod](https://zod.dev). You can pass a Zod schema to `u.input` and `u.output` to ensure that these values are validated with Zod. ```ts import { initUntypeable, createSafeClient } from "untypeable"; import { z } from "zod"; const u = initUntypeable(); const router = u.router({ "/user": u .input( z.object({ id: z.string(), }), ) .output( z.object({ id: z.string(), name: z.string(), }), ), }); export const client = createSafeClient(router, () => { // Implementation... }); ``` Now, every call made to client will have its `input` and `output` verified by the zod schemas passed. ## Configuration & Arguments `untypeable` lets you be extremely flexible with the shape of your router. Each level of the router corresponds to an argument that'll be passed to your client. ```ts // A router that looks like this: const router = u.router({ github: { "/repos": { GET: u.output(), POST: u.output(), }, }, }); const client = createTypeLevelClient(() => {}); // Will need to be called like this: client("github", "/repos", "POST"); ``` You can set up this argument structure using the methods below: ### `.pushArg` Using the `.pushArg` method when we `initUntypeable` lets us add new arguments that must be passed to our client. ```ts import { initUntypeable, createTypeLevelClient } from "untypeable"; // use .pushArg to add a new argument to // the router definition const u = initUntypeable().pushArg<"GET" | "POST" | "PUT" | "DELETE">(); type User = { id: string; name: string; }; // You can now optionally specify the // method on each route's definition const router = u.router({ "/user": { GET: u.input<{ id: string }>().output(), POST: u.input<{ name: string }>().output(), DELETE: u.input<{ id: string }>().output(), }, }); // The client now takes a new argument - method, which // is typed as 'GET' | 'POST' | 'PUT' | 'DELETE' const client = createTypeLevelClient((path, method, input) => { let resolvedPath = path; let resolvedInit: RequestInit = {}; switch (method) { case "GET": resolvedPath += `?${new URLSearchParams(input as any)}`; break; case "DELETE": case "POST": case "PUT": resolvedInit = { method, body: JSON.stringify(input), }; } return fetch(resolvedPath, resolvedInit).then((res) => res.json()); }); // This now needs to be passed to client, and // is still beautifully type-safe! const result = await client("/user", "POST", { name: "Matt", }); ``` You can call this as many times as you want! ```ts const u = initUntypeable() .pushArg<"GET" | "POST" | "PUT" | "DELETE">() .pushArg<"foo" | "bar">(); const router = u.router({ "/": { GET: { foo: u.output, }, }, }); ``` ### `.unshiftArg` You can also add an argument at the _start_ using `.unshiftArg`. This is useful for when you want to add different base endpoints: ```ts const u = initUntypeable().unshiftArg<"github", "youtube">(); const router = u.router({ github: { "/repos": u.output<{ repos: { id: string }[] }>(), }, }); ``` ### `.args` Useful for when you want to set the args up manually: ```ts const u = initUntypeable().args(); const router = u.router({ "any-string": { "any-other-string": { "yet-another-string": u.output(), }, }, }); ``` ## Organizing your routers ### `.add` You can add more detail to a router, or split it over multiple calls, by using `router.add`. ```ts const router = u .router({ "/": u.output(), }) .add({ "/user": u.output(), }); ``` ### `.merge` You can merge two routers together using `router.merge`. This is useful for when you want to combine multiple routers (perhaps in different modules) together. ```ts import { userRouter } from "./userRouter"; import { postRouter } from "./postRouter"; export const baseRouter = userRouter.merge(postRouter); ``` ================================================ FILE: src/client.ts ================================================ import { ArgsFromRouter, LooseUntypeableHandler, RoutesFromRouter, UntypeableHandler, UntypeableRouter, } from "./types"; /** * Creates an API client that does not check the input or output * at runtime. For runtime checking, use createSafeClient. * * If you're using createTypeLevelClient, we recommend importing * it from 'untypeable/client' to minimize bundle size. * * @example * * const client = createTypeLevelClient((path) => { * return fetch(path).then(res => res.json()); * }); */ export const createTypeLevelClient = < TRouter extends UntypeableRouter, TArgs extends readonly string[] = ArgsFromRouter, TRoutes extends Record = RoutesFromRouter, >( handler: LooseUntypeableHandler, ): UntypeableHandler => { return handler as any; }; ================================================ FILE: src/constants.ts ================================================ export const SEPARATOR = "__"; ================================================ FILE: src/index.ts ================================================ export * from "./client"; export * from "./types"; export * from "./untypeable"; export * from "./safe-client"; ================================================ FILE: src/playground.ts ================================================ import { initUntypeable, createTypeLevelClient } from "untypeable"; // Initialize untypeable const u = initUntypeable(); type User = { id: string; name: string; }; // Create a router // - Add typed inputs and outputs const router = u.router({ "/user": u.input<{ id: string }>().output(), }); // Create your client // - Pass any fetch implementation here const client = createTypeLevelClient((path, input) => { return fetch(path + `?${new URLSearchParams(input)}`).then((res) => res.json(), ); }); // Type-safe data access! // - user is typed as User // - { id: string } must be passed as the input const user = client("/user", { id: "1", }); ================================================ FILE: src/safe-client.ts ================================================ import { SEPARATOR } from "./constants"; import { AcceptedParser, ArgsFromRouter, LooseUntypeableHandler, RoutesFromRouter, Schemas, UntypeableHandler, UntypeableRouter, } from "./types"; export const createSafeClient = < TRouter extends UntypeableRouter, TArgs extends ArgsFromRouter, TRoutes extends RoutesFromRouter, >( router: TRouter, handler: LooseUntypeableHandler, ): UntypeableHandler => { return (async (...args: any[]) => { let schemas: Schemas; const argsExceptLast = args.slice(0, -1); const matchingSchemas = router._schemaMap.get( argsExceptLast.join(SEPARATOR), ); const secondSchemaMatchAttempt = router._schemaMap.get( args.join(SEPARATOR), ); let shouldCheckInput: boolean; if (matchingSchemas) { schemas = matchingSchemas; shouldCheckInput = true; } else if (secondSchemaMatchAttempt) { schemas = secondSchemaMatchAttempt; shouldCheckInput = false; } else { throw new Error( `No matching schema found for args: ${argsExceptLast.join(", ")}`, ); } const inputSchema = resolveParser(schemas.input); const outputSchema = resolveParser(schemas.output); let output; if (shouldCheckInput) { const input = args[args.length - 1]; const parsedInput = inputSchema(input); output = await (handler as any)(...argsExceptLast, parsedInput); } else { output = await (handler as any)(...args); } return outputSchema(output); }) as any; }; const resolveParser = (parser: AcceptedParser | undefined) => { if (typeof parser === "function") { return parser; } else if (typeof parser === "undefined") { return (x: any) => x; } else { return parser.parse; } }; ================================================ FILE: src/tests/add.test.ts ================================================ import { expect, it, vitest } from "vitest"; import { z } from "zod"; import { createSafeClient } from "../safe-client"; import { initUntypeable } from "../untypeable"; const u = initUntypeable(); const router = u .router({ inputNeeded: u.input(z.string()).output(z.object({ hello: z.string() })), }) .add({ inputNotNeeded: u.output(z.object({ hello: z.string() })), }); it('Should pick up schemas added by the "add" method', async () => { const fn = vitest.fn(); const client = createSafeClient(router, fn); fn.mockResolvedValueOnce({ hello: "world" }); await client("inputNeeded", "hello"); expect(fn).toHaveBeenLastCalledWith("inputNeeded", "hello"); fn.mockResolvedValueOnce({ hello: "world" }); await client("inputNotNeeded"); }); ================================================ FILE: src/tests/dynamic-args.test.ts ================================================ import { it, vitest } from "vitest"; import { createTypeLevelClient } from "../client"; import { initUntypeable } from "../untypeable"; it("Should let you specify 2 args", async () => { const u = initUntypeable().args<"GET" | "POST", "SOMETHING" | "ELSE">(); const router = u.router({ GET: { ELSE: u.output(), }, }); const client = createTypeLevelClient(vitest.fn()); await client("GET", "ELSE"); // @ts-expect-error await client("GET", "SOMETHING"); // @ts-expect-error await client("POST", "ELSE"); }); it("Should default to only requiring one arg", async () => { const u = initUntypeable(); const router = u.router({ GET: u.output(), }); const client = createTypeLevelClient(vitest.fn()); await client("GET"); // @ts-expect-error await client("POST"); }); ================================================ FILE: src/tests/merge.test.ts ================================================ import { expect, it, vitest } from "vitest"; import { z } from "zod"; import { createSafeClient } from "../safe-client"; import { initUntypeable } from "../untypeable"; const u = initUntypeable(); const router1 = u.router({ inputNeeded: u.input(z.string()).output(z.object({ hello: z.string() })), }); const router2 = u.router({ inputNotNeeded: u.output(z.object({ hello: z.string() })), }); const router = router2.merge(router1); it('Should pick up schemas added by the "merge" method', async () => { const fn = vitest.fn(); const client = createSafeClient(router, fn); fn.mockResolvedValueOnce({ hello: "world" }); await client("inputNeeded", "hello"); expect(fn).toHaveBeenLastCalledWith("inputNeeded", "hello"); fn.mockResolvedValueOnce({ hello: "world" }); await client("inputNotNeeded"); }); ================================================ FILE: src/tests/repl.test.ts ================================================ import { it } from "vitest"; import { initUntypeable } from "../untypeable"; it("REPL", () => { const u = initUntypeable().pushArg<"GET" | "POST">(); const router = u.router({ something: { GET: u.output(), POST: u.input().output(), }, }); }); ================================================ FILE: src/tests/router-definition.test.ts ================================================ import { describe, expect, it } from "vitest"; import { initUntypeable } from "../untypeable"; it("Should not let you pass an input directly to a route", () => { const u = initUntypeable(); expect(() => u.router({ // @ts-expect-error inputNeeded: u.input(), }), ).toThrowError(); }); it("Should let you pass an output without an input", () => { const u = initUntypeable(); u.router({ noInputNeeded: u.output(), }); }); ================================================ FILE: src/tests/type-only.test.ts ================================================ import { describe, expect, it, vitest } from "vitest"; import { createTypeLevelClient } from "../client"; import { initUntypeable } from "../untypeable"; import { Equal, Expect } from "./utils"; const u = initUntypeable(); const router = u.router({ inputNeeded: u.input().output(), noInputNeeded: u.output(), partialInputOnly: u.input<{ a?: string }>().output<{ a: string }>(), }); describe("input types", () => { it("Should require an input of the correct type if one has been specified", async () => { const fn = vitest.fn(); const client = createTypeLevelClient(fn); // @ts-expect-error await client("inputNeeded"); expect(fn).toHaveBeenLastCalledWith("inputNeeded"); await client("inputNeeded", "hello"); expect(fn).toHaveBeenLastCalledWith("inputNeeded", "hello"); // @ts-expect-error await client("inputNeeded", 0); expect(fn).toHaveBeenLastCalledWith("inputNeeded", 0); }); it("Should not require an input if one has not been specified", async () => { const fn = vitest.fn(); const client = createTypeLevelClient(fn); await client("noInputNeeded"); expect(fn).toHaveBeenLastCalledWith("noInputNeeded"); // @ts-expect-error await client("noInputNeeded", "hello"); expect(fn).toHaveBeenLastCalledWith("noInputNeeded", "hello"); }); it("Should not require an input if all properties of the input are optional", async () => { const fn = vitest.fn(); const client = createTypeLevelClient(fn); await client("partialInputOnly"); expect(fn).toHaveBeenLastCalledWith("partialInputOnly"); await client("partialInputOnly", {}); expect(fn).toHaveBeenLastCalledWith("partialInputOnly", {}); }); }); describe("Output types", () => { it("Should return an output of the correct type", async () => { const fn = vitest.fn(); const client = createTypeLevelClient(fn); const numResult = await client("inputNeeded", "adwawd"); const boolResult = await client("noInputNeeded"); type tests = [ Expect>, Expect>, ]; }); }); ================================================ FILE: src/tests/utils.ts ================================================ export type Expect = T; export type ExpectTrue = T; export type ExpectFalse = T; export type IsTrue = T; export type IsFalse = T; export type Equal = (() => T extends X ? 1 : 2) extends < T, >() => T extends Y ? 1 : 2 ? true : false; export type NotEqual = true extends Equal ? false : true; // https://stackoverflow.com/questions/49927523/disallow-call-with-any/49928360#49928360 export type IsAny = 0 extends 1 & T ? true : false; export type NotAny = true extends IsAny ? false : true; export type Debug = { [K in keyof T]: T[K] }; export type MergeInsertions = T extends object ? { [K in keyof T]: MergeInsertions } : T; export type Alike = Equal, MergeInsertions>; export type ExpectExtends = EXPECTED extends VALUE ? true : false; export type ExpectValidArgs< FUNC extends (...args: any[]) => any, ARGS extends any[], > = ARGS extends Parameters ? true : false; export type UnionToIntersection = ( U extends any ? (k: U) => void : never ) extends (k: infer I) => void ? I : never; export const doNotExecute = (func: () => any) => {}; ================================================ FILE: src/tests/with-schemas.test.ts ================================================ import { expect, it, vitest } from "vitest"; import { z } from "zod"; import { createSafeClient } from "../safe-client"; import { initUntypeable } from "../untypeable"; it("Should let you define inputs and outputs as schemas", async () => { const u = initUntypeable(); const router = u.router({ inputNeeded: u.input(z.string()).output(z.object({ hello: z.string() })), }); const fn = vitest.fn(); const client = createSafeClient(router, fn); fn.mockResolvedValueOnce({ hello: "world" }); await client("inputNeeded", "hello"); }); it("Should error if you do not provide the correct input", async () => { const u = initUntypeable(); const router = u.router({ inputNeeded: u.input(z.string()).output(z.object({ hello: z.string() })), }); const fn = vitest.fn(); const client = createSafeClient(router, fn); fn.mockResolvedValueOnce({ hello: "world" }); await client("inputNeeded", "hello"); }); it("Should error if the fetcher does not provide the correct output WITHOUT an input", async () => { const u = initUntypeable(); const router = u.router({ inputNotNeeded: u.output(z.object({ hello: z.string() })), }); const fn = vitest.fn(); const client = createSafeClient(router, fn); fn.mockResolvedValueOnce({ hello: 124 }); await expect(() => client("inputNotNeeded")).rejects.toThrowError(); }); it("Should error if the fetcher does not provide the correct output WITH an input", async () => { const u = initUntypeable(); const router = u.router({ inputNeeded: u.input(z.string()).output(z.object({ hello: z.string() })), }); const fn = vitest.fn(); const client = createSafeClient(router, fn); fn.mockResolvedValueOnce({ hello: 124 }); await expect(() => client("inputNeeded", "124123123")).rejects.toThrowError(); }); ================================================ FILE: src/types.ts ================================================ type DefaultArgs = [string]; export type AcceptedParser = | ((input: unknown) => T) | { parse: (input: unknown) => T; }; export type SchemaMap = Map; export type Schemas = { input: AcceptedParser | undefined; output: AcceptedParser | undefined; }; export interface UntypeableBase { pushArg: () => UntypeableBase<[...TArgs, TArg]>; unshiftArg: () => UntypeableBase<[TArg, ...TArgs]>; args(): UntypeableBase<[TArg1]>; args(): UntypeableBase< [TArg1, TArg2] >; args< TArg1 extends string, TArg2 extends string, TArg3 extends string, >(): UntypeableBase<[TArg1, TArg2, TArg3]>; args< TArg1 extends string, TArg2 extends string, TArg3 extends string, TArg4 extends string, >(): UntypeableBase<[TArg1, TArg2, TArg3, TArg4]>; args(): UntypeableBase; router: < TNewRoutes extends StringArrayToObject>, >( routes: TNewRoutes, ) => UntypeableRouter>; input: (parser?: AcceptedParser) => UntypeableInput; output: ( parser?: AcceptedParser, ) => UntypeableOutput<{}, TOutput>; } export type Prettify = { [K in keyof T]: T[K]; } & {}; export interface UntypeableInput { __type: "input"; output: ( schema?: AcceptedParser, ) => UntypeableOutput; inputSchema: AcceptedParser | undefined; } export interface UntypeableOutput { __type: "output"; outputSchema: AcceptedParser | undefined; inputSchema: AcceptedParser | undefined; } export interface UntypeableRouter< TArgs extends readonly string[] = DefaultArgs, TRoutes extends Record = {}, > { _schemaMap: SchemaMap; add: < TNewRoutes extends StringArrayToObject>, >( routes: TNewRoutes, ) => UntypeableRouter>; merge: >( router: UntypeableRouter, ) => UntypeableRouter>; } export type LooseUntypeableHandler = ( ...args: [...args: TArgs, input: any] ) => Promise; export type UntypeableHandler< TArgs extends readonly string[] = DefaultArgs, TRoutes extends Record = {}, > = TArgs["length"] extends 1 ? ( firstArg: T1, ...args: ArgsFromRoutes ) => TRoutes[T1] extends UntypeableOutput ? Promise : never : TArgs["length"] extends 2 ? ( firstArg: T1, secondArg: T2, ...args: ArgsFromRoutes ) => TRoutes[T1][T2] extends UntypeableOutput ? Promise : never : TArgs["length"] extends 3 ? < T1 extends keyof TRoutes, T2 extends keyof TRoutes[T1], T3 extends keyof TRoutes[T1][T2], >( firstArg: T1, secondArg: T2, thirdArg: T3, ...args: ArgsFromRoutes ) => TRoutes[T1][T2][T3] extends UntypeableOutput ? Promise : never : TArgs["length"] extends 4 ? < T1 extends keyof TRoutes, T2 extends keyof TRoutes[T1], T3 extends keyof TRoutes[T1][T2], T4 extends keyof TRoutes[T1][T2][T3], >( firstArg: T1, secondArg: T2, thirdArg: T3, fourthArg: T4, ...args: ArgsFromRoutes ) => TRoutes[T1][T2][T3][T4] extends UntypeableOutput ? Promise : never : never; export type ArgsFromRoutes = TRoutes extends UntypeableOutput< infer TInput, any > ? TInput extends Record ? [] : Record extends TInput ? [input?: TInput] : [input: Prettify] : never; export type StringArrayToObject< T extends readonly string[], TValue, > = T extends [ infer THead extends string, ...infer TTail extends readonly string[], ] ? { [K in THead]?: StringArrayToObject } : TValue; export type ArgsFromRouter> = TRouter extends UntypeableRouter ? TArgs : never; export type RoutesFromRouter> = TRouter extends UntypeableRouter ? TRoutes : never; ================================================ FILE: src/untypeable.ts ================================================ import { SEPARATOR } from "./constants"; import { UntypeableBase, UntypeableInput, UntypeableOutput, UntypeableRouter, AcceptedParser, SchemaMap, } from "./types"; const isOutput = (value: { __type?: string; }): value is UntypeableOutput => { return value.__type === "output"; }; const isInput = (value: { __type?: string; }): value is UntypeableOutput => { return value.__type === "input"; }; const combineMaps = (map1: Map, map2: Map) => { const newMap = new Map(map1); for (const [key, value] of map2) { newMap.set(key, value); } return newMap; }; const initRouter = = {}>( routes: TRoutes, schemaMap: SchemaMap = new Map(), ): UntypeableRouter => { const collectRoutes = ( routes: Record | Record>, path: string[] = [], ) => { for (const [key, value] of Object.entries(routes)) { if (isOutput(value)) { schemaMap.set([...path, key].join(SEPARATOR), { input: value.inputSchema, output: value.outputSchema, }); } else if (isInput(value)) { throw new Error("An input cannot be passed directly to a router."); } else { collectRoutes(value, [...path, key]); } } }; collectRoutes(routes); return { add: (newRoutes) => initRouter(newRoutes, schemaMap), merge: (router) => initRouter({}, combineMaps(router._schemaMap, schemaMap)), _schemaMap: schemaMap, }; }; const initInput = ( inputSchema: AcceptedParser | undefined, ): UntypeableInput => { return { output: (outputSchema) => initOutput(inputSchema, outputSchema), inputSchema, __type: "input", }; }; const initOutput = ( inputSchema: AcceptedParser | undefined, outputSchema: AcceptedParser | undefined, ): UntypeableOutput => { return { __type: "output", inputSchema, outputSchema, }; }; export const initUntypeable = (): UntypeableBase => { return { pushArg: () => initUntypeable() as any, unshiftArg: () => initUntypeable() as any, args: () => initUntypeable() as any, input: initInput, output: (outputSchema) => initOutput(undefined, outputSchema), router: (routes) => initRouter(routes), }; }; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { /* Visit https://aka.ms/tsconfig to read more about this file */ /* Projects */ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ "module": "NodeNext", /* Specify what module code is generated. */ // "rootDir": "./", /* Specify the root folder within your source files. */ // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ // "resolveJsonModule": true, /* Enable importing .json files. */ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ /* JavaScript Support */ // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ /* Emit */ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ // "declarationMap": true, /* Create sourcemaps for d.ts files. */ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ // "outDir": "./", /* Specify an output folder for all emitted files. */ // "removeComments": true, /* Disable emitting comments. */ "noEmit": true, /* Disable emitting files from a compilation. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ // "newLine": "crlf", /* Set the newline character for emitting files. */ // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ /* Interop Constraints */ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ /* Type Checking */ "strict": true, /* Enable all strict type-checking options. */ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, "exclude": [ "dist" ] } ================================================ FILE: tsup.config.ts ================================================ import { defineConfig } from "tsup"; const config = defineConfig({ format: ["cjs", "esm"], dts: true, entry: { index: "src/index.ts", client: "src/client.ts", }, }); export default config;