[{"data":1,"prerenderedAt":455},["ShallowReactive",2],{"blog:auto-generating-typescript-api-client":3},{"id":4,"title":5,"body":6,"date":440,"description":441,"draft":442,"extension":443,"meta":444,"navigation":66,"path":445,"seo":446,"stem":447,"tags":448,"__hash__":454},"blog\u002Fblog\u002Fauto-generating-typescript-api-client.md","Auto-generating a TypeScript API Client from OpenAPI with @hey-api\u002Fopenapi-ts",{"type":7,"value":8,"toc":433},"minimark",[9,13,21,26,34,200,203,210,214,226,243,290,293,378,382,388,392,395,412,415,419,426,429],[10,11,12],"p",{},"One of the most common sources of bugs in full-stack apps is the frontend and backend diverging silently.\nYou rename a field on the API, forget to update the TypeScript interface, and two weeks later you get a production bug report.",[10,14,15,16,20],{},"At work, we built a new PHP\u002FLaminas backend alongside a Nuxt 3 frontend. From day one, I wanted to make the contract between them ",[17,18,19],"em",{},"machine-enforced",".",[22,23,25],"h2",{"id":24},"the-setup","The setup",[10,27,28,29,33],{},"The backend uses ",[30,31,32],"strong",{},"OpenAPI attributes"," on every handler and DTO:",[35,36,41],"pre",{"className":37,"code":38,"language":39,"meta":40,"style":40},"language-php shiki shiki-themes one-dark-pro","\u003C?php\n\n#[OA\\Get(\n    path: '\u002Fcustomers',\n    operationId: 'getCustomers',\n    responses: [\n        new OA\\Response(response: 200, description: 'List of customers', content: new OA\\JsonContent(ref: CustomerListResponse::class))\n    ]\n)]\nclass GetCustomersHandler\n{\n    \u002F\u002F ...\n}\n","php","",[42,43,44,61,68,74,87,98,104,161,167,173,181,187,194],"code",{"__ignoreMap":40},[45,46,49,53,57],"span",{"class":47,"line":48},"line",1,[45,50,52],{"class":51},"sjrmR","\u003C",[45,54,56],{"class":55},"seHd6","?",[45,58,60],{"class":59},"sn6KH","php\n",[45,62,64],{"class":47,"line":63},2,[45,65,67],{"emptyLinePlaceholder":66},true,"\n",[45,69,71],{"class":47,"line":70},3,[45,72,73],{"class":59},"#[OA\\Get(\n",[45,75,77,80,84],{"class":47,"line":76},4,[45,78,79],{"class":59},"    path: ",[45,81,83],{"class":82},"subq3","'\u002Fcustomers'",[45,85,86],{"class":59},",\n",[45,88,90,93,96],{"class":47,"line":89},5,[45,91,92],{"class":59},"    operationId: ",[45,94,95],{"class":82},"'getCustomers'",[45,97,86],{"class":59},[45,99,101],{"class":47,"line":100},6,[45,102,103],{"class":59},"    responses: [\n",[45,105,107,110,113,117,120,124,127,130,133,135,138,141,143,146,149,152,155,158],{"class":47,"line":106},7,[45,108,109],{"class":55},"        new",[45,111,112],{"class":59}," OA\\",[45,114,116],{"class":115},"sU0A5","Response",[45,118,119],{"class":59},"(response:",[45,121,123],{"class":122},"sVC51"," 200",[45,125,126],{"class":59},",",[45,128,129],{"class":59}," description:",[45,131,132],{"class":82}," 'List of customers'",[45,134,126],{"class":59},[45,136,137],{"class":59}," content:",[45,139,140],{"class":55}," new",[45,142,112],{"class":59},[45,144,145],{"class":115},"JsonContent",[45,147,148],{"class":59},"(ref:",[45,150,151],{"class":115}," CustomerListResponse",[45,153,154],{"class":59},"::",[45,156,157],{"class":55},"class",[45,159,160],{"class":59},"))\n",[45,162,164],{"class":47,"line":163},8,[45,165,166],{"class":59},"    ]\n",[45,168,170],{"class":47,"line":169},9,[45,171,172],{"class":59},")]\n",[45,174,176,178],{"class":47,"line":175},10,[45,177,157],{"class":55},[45,179,180],{"class":115}," GetCustomersHandler\n",[45,182,184],{"class":47,"line":183},11,[45,185,186],{"class":59},"{\n",[45,188,190],{"class":47,"line":189},12,[45,191,193],{"class":192},"sV9Aq","    \u002F\u002F ...\n",[45,195,197],{"class":47,"line":196},13,[45,198,199],{"class":59},"}\n",[10,201,202],{},"A PHPStan rule enforces that every handler has OpenAPI documentation — missing it fails CI.",[10,204,205,206,209],{},"From those attributes, we generate an ",[42,207,208],{},"openapi.json"," spec file. That spec is the single source of truth.",[22,211,213],{"id":212},"generating-the-typescript-client","Generating the TypeScript client",[10,215,216,225],{},[217,218,222],"a",{"href":219,"rel":220},"https:\u002F\u002Fheyapi.dev\u002F",[221],"nofollow",[42,223,224],{},"@hey-api\u002Fopenapi-ts"," reads the OpenAPI spec and generates:",[227,228,229,233,240],"ul",{},[230,231,232],"li",{},"Typed request\u002Fresponse interfaces",[230,234,235,236,239],{},"A fully typed client (we use the ",[42,237,238],{},"fetch"," client)",[230,241,242],{},"SDK methods for every operation",[35,244,248],{"className":245,"code":246,"language":247,"meta":40,"style":40},"language-bash shiki shiki-themes one-dark-pro","npx @hey-api\u002Fopenapi-ts \\\n  --input .\u002Fopenapi.json \\\n  --output .\u002Fsrc\u002Fclient \\\n  --client @hey-api\u002Fclient-fetch\n","bash",[42,249,250,262,272,282],{"__ignoreMap":40},[45,251,252,256,259],{"class":47,"line":48},[45,253,255],{"class":254},"sVbv2","npx",[45,257,258],{"class":82}," @hey-api\u002Fopenapi-ts",[45,260,261],{"class":51}," \\\n",[45,263,264,267,270],{"class":47,"line":63},[45,265,266],{"class":122},"  --input",[45,268,269],{"class":82}," .\u002Fopenapi.json",[45,271,261],{"class":51},[45,273,274,277,280],{"class":47,"line":70},[45,275,276],{"class":122},"  --output",[45,278,279],{"class":82}," .\u002Fsrc\u002Fclient",[45,281,261],{"class":51},[45,283,284,287],{"class":47,"line":76},[45,285,286],{"class":122},"  --client",[45,288,289],{"class":82}," @hey-api\u002Fclient-fetch\n",[10,291,292],{},"The generated client looks like this on the consuming side:",[35,294,298],{"className":295,"code":296,"language":297,"meta":40,"style":40},"language-typescript shiki shiki-themes one-dark-pro","import { getCustomers } from '@acme\u002Fapi-client'\n\nconst { data, error } = await getCustomers({ query: { page: 1 } })\n\u002F\u002F data is fully typed — CustomerListResponse\n","typescript",[42,299,300,321,325,373],{"__ignoreMap":40},[45,301,302,305,308,312,315,318],{"class":47,"line":48},[45,303,304],{"class":55},"import",[45,306,307],{"class":59}," { ",[45,309,311],{"class":310},"sVyAn","getCustomers",[45,313,314],{"class":59}," } ",[45,316,317],{"class":55},"from",[45,319,320],{"class":82}," '@acme\u002Fapi-client'\n",[45,322,323],{"class":47,"line":63},[45,324,67],{"emptyLinePlaceholder":66},[45,326,327,330,332,335,338,341,343,346,349,352,355,358,361,364,367,370],{"class":47,"line":70},[45,328,329],{"class":55},"const",[45,331,307],{"class":59},[45,333,334],{"class":115},"data",[45,336,337],{"class":59},", ",[45,339,340],{"class":115},"error",[45,342,314],{"class":59},[45,344,345],{"class":51},"=",[45,347,348],{"class":55}," await",[45,350,351],{"class":254}," getCustomers",[45,353,354],{"class":59},"({ ",[45,356,357],{"class":310},"query",[45,359,360],{"class":59},": { ",[45,362,363],{"class":310},"page",[45,365,366],{"class":59},": ",[45,368,369],{"class":122},"1",[45,371,372],{"class":59}," } })\n",[45,374,375],{"class":47,"line":76},[45,376,377],{"class":192},"\u002F\u002F data is fully typed — CustomerListResponse\n",[22,379,381],{"id":380},"publishing-to-a-private-registry","Publishing to a private registry",[10,383,384,385,20],{},"We publish the generated client as a private npm package. The frontend installs it like any other dependency: ",[42,386,387],{},"npm install @acme\u002Fapi-client",[22,389,391],{"id":390},"keeping-it-up-to-date-github-actions","Keeping it up to date: GitHub Actions",[10,393,394],{},"The real value comes from automation. A GitHub Actions workflow:",[396,397,398,403,406,409],"ol",{},[230,399,400,401],{},"Runs on every push that touches PHP files or ",[42,402,208],{},[230,404,405],{},"Regenerates the client",[230,407,408],{},"Bumps the patch version",[230,410,411],{},"Publishes the new package to the registry",[10,413,414],{},"The frontend's Renovate config picks up the new version automatically and opens a PR.",[22,416,418],{"id":417},"the-result","The result",[10,420,421,422,425],{},"The gap between API contract and frontend types is now ",[30,423,424],{},"zero"," — they're the same file. Schema drift is impossible without a failing CI job. And new developers don't need to figure out the API shape by reading PHP code; they just look at the TypeScript types.",[10,427,428],{},"This pattern works equally well with any typed backend that can emit an OpenAPI spec.",[430,431,432],"style",{},"html pre.shiki code .sjrmR, html code.shiki .sjrmR{--shiki-default:#56B6C2}html pre.shiki code .seHd6, html code.shiki .seHd6{--shiki-default:#C678DD}html pre.shiki code .sn6KH, html code.shiki .sn6KH{--shiki-default:#ABB2BF}html pre.shiki code .subq3, html code.shiki .subq3{--shiki-default:#98C379}html pre.shiki code .sU0A5, html code.shiki .sU0A5{--shiki-default:#E5C07B}html pre.shiki code .sVC51, html code.shiki .sVC51{--shiki-default:#D19A66}html pre.shiki code .sV9Aq, html code.shiki .sV9Aq{--shiki-default:#7F848E;--shiki-default-font-style:italic}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sVbv2, html code.shiki .sVbv2{--shiki-default:#61AFEF}html pre.shiki code .sVyAn, html code.shiki .sVyAn{--shiki-default:#E06C75}",{"title":40,"searchDepth":63,"depth":63,"links":434},[435,436,437,438,439],{"id":24,"depth":63,"text":25},{"id":212,"depth":63,"text":213},{"id":380,"depth":63,"text":381},{"id":390,"depth":63,"text":391},{"id":417,"depth":63,"text":418},"2026-02-14","How I replaced hand-maintained API types with an auto-generated, fully typed TypeScript client — published to a private registry and rebuilt on every API spec change via GitHub Actions.",false,"md",{},"\u002Fblog\u002Fauto-generating-typescript-api-client",{"title":5,"description":441},"blog\u002Fauto-generating-typescript-api-client",[449,450,451,452,453],"TypeScript","OpenAPI","GitHub Actions","Developer Experience","PHP","z_jJ5nYdE1aHxAGI0_qbV91-FhUo3uwPulpkEvZVMoo",1776339762988]