Anthony Wentzel

MoyaJS: the network layer I never finished

MoyaJS was my 2017 port of the Moya Swift networking library. It never shipped, but its endpoints-as-data idea still shapes my TypeScript API clients.

From 2017 · 3 min read · Published September 14, 2026

In January 2017, a friend and I created a GitHub org called MoyaJS. The plan was to port Moya, a beloved Swift networking library, to JavaScript as MoyaJS. We set up CI, wired up code coverage, designed a logo, and wrote a README.

The main source file, in its entirety, reads:

index.js
// hi

That's the whole implementation. I think it's worth writing about anyway, because the idea behind it has shaped every API client I've built since.

The problem

The README put it this way:

You probably use Axios to abstract away access to http and all those nasty details you don't really care about. But then, like lots of smart developers, you write ad hoc network abstraction layers. They are probably called "APIManager" or "NetworkModel", and they always end in tears.

Every codebase I've worked in has one of these. A file full of functions that each build a URL, set some headers, pick a method, and call fetch. The knowledge of what the API is ends up scattered across all of those functions.

Moya's idea: endpoints are data, even in a TypeScript API client

Moya's insight was to describe your API as a closed set of cases, where each case knows its path, method, parameters, and, crucially, a sample response for tests. The networking code then becomes one generic function over that description.

In 2017 JavaScript, that was awkward. In TypeScript today it's natural:

api.ts
type Target =
  | { kind: 'user'; id: string }
  | { kind: 'rides'; userId: string; page?: number }
  | { kind: 'cancelRide'; rideId: string }
 
function describe(t: Target) {
  switch (t.kind) {
    case 'user':
      return { method: 'GET', path: `/users/${t.id}` }
    case 'rides':
      return { method: 'GET', path: `/users/${t.userId}/rides`, query: { page: t.page ?? 1 } }
    case 'cancelRide':
      return { method: 'POST', path: `/rides/${t.rideId}/cancel` }
  }
}

The compiler now knows every endpoint that exists. Add a case and forget to describe it, and the switch stops type-checking. Nothing about URLs lives anywhere else.

Stubs as first-class citizens

The part of Moya I liked most was that sample data was part of the endpoint definition, not something you remember to mock later:

const samples: { [K in Target['kind']]: unknown } = {
  user: { id: 'u_1', name: 'Test User' },
  rides: { items: [], page: 1 },
  cancelRide: { ok: true },
}
 
export function createClient(opts: { stub?: boolean; baseUrl: string }) {
  return async (t: Target) => {
    if (opts.stub) return samples[t.kind] 
    const { method, path, query } = describe(t)
    const url = new URL(path, opts.baseUrl)
    for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, String(v))
    const res = await fetch(url, { method })
    if (!res.ok) throw new Error(`${method} ${path} failed: ${res.status}`)
    return res.json()
  }
}

Tests, Storybook, and offline development all get realistic data for free, and the mapped type guarantees every endpoint has a sample.

Why it never shipped

Honestly, we got busy building products, and a networking library for other people's products lost. It happens.

But I've written some version of this pattern in almost every app since, in React, React Native, and now in the tool registries behind AI agents, which are the same idea again: a closed, typed set of operations described as data, with one generic executor. The library didn't survive. The idea did.


Related: Wrapping a native SDK in a React Native native module · Yeoman generators: code conventions you can run