Screenshot of the Conduit interface
Category
Personal
Year

2023

Type
Web App
Overview

A full Medium-style blogging platform — the RealWorld spec — built to practice the parts of a real app a to-do list never teaches: authentication, an infinite feed, a markdown editor, and a data layer that refuses to trust the network.

Highlights
  • Implemented every screen of the RealWorld spec against a shared public API — sign-in and sign-up, profiles, favoriting, the markdown editor, and an infinite-scrolling article feed.

  • Validated every API response at runtime with io-ts codecs, so a malformed payload becomes a typed, recoverable error instead of an undefined crashing three components deep.

  • Modeled the data layer with fp-ts: requests resolve to an Either, expected failures travel as values, and only the genuinely unexpected is ever thrown.

  • Wrapped Radix primitives into a small, accessible component library and documented each one in Storybook, deployed on its own.

Stack
TypeScriptNext.jsReact Queryfp-tsio-tsRadix UIStyled ComponentsStorybook

Conduit is the RealWorld app — a Medium-style blogging platform that hundreds of developers rebuild to compare stacks. The spec hands you the design and a public API; what you bring is the front end. I took it on for one reason: I wanted to practice the parts of a real application that a to-do list quietly skips.

A to-do list lies to you

Most starter projects render some local state and call it a day. A real product has to authenticate a user, page through data that doesn't fit on one screen, accept long-form input, render untrusted markdown, and — the part nobody mentions — survive a server that occasionally lies to it. Conduit has all of those, against an API I didn't write and couldn't change. That last constraint is the whole point: the network is a boundary I don't control, so I decided to treat it like one.

Don't trust the network — decode it

By the time a JSON response reaches a component, TypeScript has already stopped helping. response.json() is any; the interface you wrote over it is a hopeful fiction. So every response passes through an io-ts codec before anything downstream sees it:

export const ArticleCodec = type({
  slug: withMessage(NonEmptyString, () => "slug should be a non-empty string"),
  title: withMessage(NonEmptyString, () => "title should be a non-empty string"),
  body: withMessage(NonEmptyString, () => "body should be a non-empty string"),
  author: AuthorCodec,
  favoritesCount: withMessage(number, () => "favoritesCount should be a number"),
})

The codec is the single source of truth: the runtime check and the static type are derived from the same definition, so they can't drift apart. fetcher runs the decode and hands back an Either — a value that is either a validation error or the data, never a surprise:

const validateCodec = <A>(codec: Type<A>, data: A) =>
  pipe(
    data,
    codec.decode,
    match(
      (errors) => left(new ValidationError(message, errors.join(", "))),
      (value) => right(value),
    ),
  )

A malformed payload becomes a typed ValidationError I can render as an honest error state — not an undefined that detonates three components deep. I liked this pattern enough to write it up on its own, in Handling errors in TypeScript.

Failures are values, not exceptions

Once fetcher returns an Either, the rest of the app stops reaching for try/catch to steer. Expected failures — a 422, an expired token, an empty result — travel as data; only the genuinely unexpected is thrown. Pagination is the nicest example: the feed is an infinite query, and "is there a next page?" is a pure function of the last response.

getNextPageParam: (lastPage) =>
  pipe(
    lastPage,
    match(
      () => null, // a failed page has no next page
      ({ articles, articlesCount }) =>
        articles.length === articlesCount
          ? null // every article is loaded
          : { limit: String(articles.length + defaultArticlesLimit) },
    ),
  )

No mutable counters, no off-by-one. React Query caches the result and an IntersectionObserver asks for the next page when the sentinel scrolls into view. I leaned on fp-ts heavily here — more of that thinking is in Functional programming in JavaScript.

Components I'd actually reuse

The UI is a small library, not a pile of one-offs. Dialogs, tabs, popovers, and toasts are built on Radix primitives, so focus traps and keyboard behavior are handled by people who've thought about them harder than I have. Each component is documented in Storybook and themed with Styled Components. Auth is a cookie-backed state machine — idle → loggedIn → loggedOut — so the UI never has to guess whether it knows who you are.

A to-do app teaches you React. A spec like RealWorld teaches you the parts of a real app everyone skips — and the ones that actually break in production.

What I'd do differently

fp-ts and io-ts did exactly what I asked of them, but they're a steep ask for anyone joining the codebase, and the ecosystem has moved since. Today I'd reach for Zod to decode and something lighter — neverthrow, or Effect, where a lot of the fp-ts energy went — for the Either flow. I'd also collapse the duplication between my list and detail codecs by composing one from the other, and lift the auth token out of a cookie that's re-read on every request into a single source. None of that changes the lesson, though: validate at the edges, model failure as a value, and the inside of the app gets quiet.