The TypeScript error only CI could see: amplify_outputs.json, type-only imports, and a green local gate
Every check on my machine was green. npm run lint, npx jest with 1622 tests, next build — and, crucially, the dedicated backend typecheck my own engineering standards demand before any pull request:
npx tsc --noEmit -p amplify/tsconfig.json
I pushed. The Amplify staging build failed in the backend phase, before a single line of the frontend was compiled:
[SyntaxError] TypeScript validation check failed.
lib/admin/courses/client.ts:7:21 - error TS2307: Cannot find module '../../../amplify_outputs.json'
The same command, the same project file, the same TypeScript version — green locally, red in CI. This post is the anatomy of that gap, because the lesson generalizes far past Amplify: a pre-deploy gate has to reproduce CI's file-system state, not just its compiler scope.
TL;DR
amplify_outputs.jsonis generated, gitignored, and never committed.ampx pipeline-deploytype-checks the backend graph before it writes that file.- Locally the file exists (
ampx sandboxwrote it), so the identical typecheck resolves it happily. That is the entire difference. - A type-only import (
import type { X } from './client') still resolves./client. It pulls that module — and everything it imports — into the program. - Fix: move the types into a module that imports no generated files, and point every backend-reachable module at it.
- Gate it for good: run the backend typecheck with the generated file temporarily hidden.
The setup: why backend code was reading app code at all
The project is a Next.js App Router app on Amplify Gen 2. Two features run their slow work as background jobs in Lambda: a course-import worker and, more recently, an assessment worker that calls an external scoring API and writes results back.
Those handlers live under amplify/functions/**, and they need the same domain logic the web app uses — reading courses, walking lessons, updating job rows. Rather than duplicate it, they import the shared modules in lib/ by relative path, exactly as the house rules prescribe:
Keep backend-reachable shared code on relative imports, never
@/aliases, so both compiler scopes and the Lambda bundler resolve it identically.
That rule was written after an earlier deploy failure in a sibling project — same error code, different cause. It is good advice, and it was followed. It just was not enough.
How a type-only import dragged a JSON file into the deploy
The shared modules take their Amplify data client as a parameter, and they typed it like this:
// lib/admin/courses/reads.ts
// Types only: importing the client module's *values* would pull Next's server
// runtime in at module scope and make this module untestable without it.
import type { CourseAuthMode, CourseClient } from './client'
The comment shows the author already knew values were dangerous here. What it misses is that import type is not a free ride. TypeScript still has to find ./client to know what CourseClient means, so client.ts joins the program — and client.ts starts like this:
// lib/admin/courses/client.ts
import { createServerRunner } from '@aws-amplify/adapter-nextjs'
import { generateServerClientUsingCookies } from '@aws-amplify/adapter-nextjs/api'
import { cookies } from 'next/headers'
import type { Schema } from '../../../amplify/data/resource'
import outputs from '../../../amplify_outputs.json'
const { runWithAmplifyServerContext } = createServerRunner({ config: outputs })
The chain that reached CI is therefore:
amplify/functions/course-import-worker/handler.ts
→ lib/admin/courses/import-jobs.ts
→ import type { CourseClient } from './client'
→ lib/admin/courses/client.ts
→ import outputs from '../../../amplify_outputs.json' ← does not exist yet
Six shared modules had that import — reads, import-jobs, translations, enumeration, audio-jobs, tts-jobs — and each of them is reachable from a worker.
Why every local check passed
amplify/tsconfig.json is the right scope — it is exactly what the deploy validates. The gate was not wrong about which files to compile. It was wrong about what exists.
| Local | Amplify CI | |
|---|---|---|
| Compiler scope | amplify/tsconfig.json | amplify/tsconfig.json |
| Files pulled in | worker → lib/** → client.ts | identical |
amplify_outputs.json | present (ampx sandbox wrote it) | not yet generated |
| Result | green | TS2307 |
ampx pipeline-deploy runs its TypeScript validation of the backend graph first, and only then deploys the backend and writes amplify_outputs.json. The file whose absence broke the check is an output of the very step being gated. Locally that ordering is invisible, because a sandbox wrote the file weeks ago and it has been sitting there ever since.
This is a whole class of bug: any import of a build-time-generated artifact is a deploy-time landmine, and no amount of compiler configuration will reveal it while the artifact is lying around on your disk.
Reproducing CI in one command
The trick is to make your machine look like a fresh CI checkout — hide the generated file, then run the exact gate:
mv amplify_outputs.json /tmp/outputs.bak
npx tsc --noEmit -p amplify/tsconfig.json
mv /tmp/outputs.bak amplify_outputs.json
That reproduced the failure immediately, and it confirmed the error was the only one — nothing else in the backend graph depended on generated files.
To see why a file is in the program, add --explainFiles. It prints the import chain that dragged each file in:
mv amplify_outputs.json /tmp/outputs.bak
npx tsc --noEmit -p amplify/tsconfig.json --explainFiles
mv /tmp/outputs.bak amplify_outputs.json
The output reads like a stack trace for module resolution:
lib/admin/courses/client.ts
Imported via './client' from file 'lib/admin/courses/reads.ts'
lib/admin/courses/reads.ts
Imported via '../../../lib/admin/courses/reads' from file
'amplify/functions/course-import-worker/handler.ts'
Three lines of output replaced an hour of guessing. If you take one tool away from this post, take --explainFiles.
The fix: types that touch nothing generated
The types themselves need none of what client.ts imports. CourseClient can be derived straight from the adapter's factory function, so it lives happily in a module with no generated-file imports at all:
// lib/admin/courses/client-types.ts
import type { generateServerClientUsingCookies } from '@aws-amplify/adapter-nextjs/api'
import type { Schema } from '../../../amplify/data/resource'
/** Signed-in admin/editor reads and every write; `iam` covers guest reads. */
export type CourseAuthMode = 'userPool' | 'iam'
export type CourseClient = ReturnType<
typeof generateServerClientUsingCookies<Schema>
>
amplify/data/resource is fine to import: it is source, checked into the repo, and already part of the backend program. Then the six worker-reachable modules change one line each:
// before — reaches client.ts, and through it the generated JSON
import type { CourseAuthMode, CourseClient } from './client'
// after — reaches nothing that CI has yet to generate
import type { CourseAuthMode, CourseClient } from './client-types'
And client.ts re-exports the types so its server-only consumers keep working untouched:
// lib/admin/courses/client.ts
import type { CourseAuthMode, CourseClient } from './client-types'
export type { CourseAuthMode, CourseClient } from './client-types'
The stumble worth documenting
My first push had only the export type { … } from './client-types' line. A re-export does not bring the name into the module's own scope — it forwards it to importers and nothing more. client.ts still uses CourseClient in its own signatures, so it needs its own import type as well. Both lines, not one.
The root typecheck caught it minutes later. Which is the small, encouraging half of this story: the layered gates do work, once each layer is actually testing what it claims to test.
The rule that generalizes
Amplify is just the setting here. The shape of the bug is everywhere generated code lives next to source: amplify_outputs.json, .env.generated, Prisma clients, GraphQL codegen output, protobuf stubs, next-env.d.ts.
Three rules I now apply:
- A pre-deploy gate must reproduce CI's file-system state, not just its compiler scope. If CI starts from a clean checkout, your gate should too — hide the generated artifacts and re-run.
- Type-only imports still resolve modules.
import typeerases the emit, not the resolution. Anything you can reach through a type annotation is part of your compile. - Keep backend-reachable shared code free of build-time artifacts. If a module imports generated config, framework runtime (
next/headers), or environment-specific files, it is not shared code — it is server code, and only the server may import it.
The house rule for this project now reads: any change touching amplify/** or shared code the backend imports gets the backend typecheck with generated files hidden, before the pull request. It costs about four seconds.