The Contract Between Angular and the API: OpenAPI Documents, Breaking Change Detection and When to Use Pact
The contract between Angular and the API is carried by an OpenAPI 3.1 document generated at build time and committed; in CI, oasdiff catches breaking changes, while TypeScript types generated from the document and tsc catch breakage in the fields actually used. With real output we explain why the document shows numbers as 'integer or string', the enum, nullable and required pitfalls, how to classify breaking changes and when Pact is needed.
Version note: The code in this post was built and run with .NET 10, Microsoft.AspNetCore.OpenApi 10.0.15, Microsoft.Extensions.ApiDescription.Server 10.0.15, oasdiff 1.37, openapi-typescript 7.13, Angular 22.2 and TypeScript 6.0 (Angular 22.2 requires TypeScript 6.0, so the Angular side uses 6.0 rather than TypeScript 7).
Guess First
Bookstore's Angular UI gets the book list from the API. Instead of writing the types by hand, you decided to generate them from the OpenAPI document the API produces. In the document .NET 10 generates, BookDto looks like this (shortened):
"BookDto": {
"required": ["id", "isbn", "title", "price", "stock"],
"type": "object",
"properties": {
"id": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": ["integer", "string"], "format": "int32" },
"price": { "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", "type": ["number", "string"], "format": "double" },
"stock": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": ["integer", "string"], "format": "int32" }
}
}
openapi-typescript generated stock: number | string from this. And here is a small helper function on the Angular side:
export function stockLabel(book: BookDto): string {
return book.stock > 0 ? `${book.stock} in stock` : 'Out of stock';
}
Two questions: why did a field that is int Stock in C# end up in the document as "integer or string"? And what does tsc say about this function?
The Answer: The Client Believes Whatever the Document Says
The TypeScript build stopped:
src/app/catalog/labels.ts(4,10): error TS2365: Operator '>' cannot be applied to types 'string | number' and 'number'.
src/app/catalog/labels.ts(14,13): error TS2322: Type 'string' is not assignable to type 'never'.
The document isn't wrong, but it doesn't answer the question being asked. Minimal APIs use the web defaults for JSON, and with those defaults numbers can also be read from strings; a client sending {"quantity": "2"} gets its request accepted. The OpenAPI generator reflects that serializer setting in the document, without distinguishing request from response. The result: although the API always returns numbers in responses, the generated types force the Angular code to deal with number | string everywhere. The second error is from the same family: the order status is an enum in C#, but because it is a string in the DTO, the document only says "type": "string" and the switch in Angular cannot prove it handles every status.
Both fixes are on the API side, in the code that produces the document:
builder.Services.AddOpenApi();
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict);
// The enum is written by name in JSON; it also goes into the OpenAPI document as a list of values
[JsonConverter(typeof(JsonStringEnumConverter<OrderStatus>))]
public enum OrderStatus
{
Placed,
Cancelled
}
public sealed record OrderDto(int Id, string Reference, OrderStatus Status, decimal Total, string? TrackingNumber, DateTimeOffset CreatedAt);
Afterwards the numbers in BookDto became "type": "integer" and "type": "number", and OrderStatus entered the document as {"enum": ["Placed", "Cancelled"]}. The generated TypeScript type became stock: number and OrderStatus: "Placed" | "Cancelled", and tsc passed without errors. The status value in JSON was already written as "Placed" and "Cancelled"; the wire format did not change, only the document became honest. The assertion of the cancellation test in the test project now compares against OrderStatus.Cancelled instead of a string.
Strict, on the other hand, changes behavior on the wire. When I sent the same order request with "quantity": "1", the API now returned 400; before, the document said it accepted that. So fixing the document can break a client. If the Angular client sends numbers as numbers there is no problem; but the decision should be made consciously and other clients should be asked.
Generating the OpenAPI Document at Build Time
.NET 10 produces an OpenAPI 3.1 document by default; the first line of the file I generated is "openapi": "3.1.1". To get the document as a file during the build, and not only from the running app's /openapi/v1.json endpoint, the Microsoft.Extensions.ApiDescription.Server package is enough:
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<!-- The document is written to the repo's contracts folder and committed -->
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../../contracts</OpenApiDocumentsDirectory>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.15" />
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
Every dotnet build rewrites contracts/Bookstore.Api.json. According to Learn, build-time generation works by running the app's entry point with a mock server; so Program.cs also runs during the build. If there is code that applies migrations, connects to a secret store or calls an external service at startup, Learn's advice is to skip those paths when the entry assembly name is GetDocument.Insider. Bookstore's Program.cs doesn't touch anything external at startup, so no extra condition was needed.
Keeping the file in the repo has two benefits. First, every contract change in the API shows up in the pull request as a JSON diff; the reviewer reads what the C# code changed in terms of what goes over the wire. Second, the Angular side can generate types from this file without needing a running server.
The Angular Side: Types Generated from the Document
I generate the types with openapi-typescript. When I tried to add the package to the Angular project as a devDependency, npm stopped:
Could not resolve dependency:
peer typescript@"^5.x" from openapi-typescript@7.13.0
node_modules/openapi-typescript
dev openapi-typescript@"7.13.0" from the root project
openapi-typescript 7.13 wants TypeScript 5 as a peer dependency, while Angular 22.2 wants TypeScript 6.0. Instead of forcing it with --legacy-peer-deps, I ran the tool through npx with a pinned version, keeping it out of the project's dependency tree:
{
"scripts": {
"api:types": "npx --yes openapi-typescript@7.13.0 ../contracts/Bookstore.Api.json -o src/app/api/bookstore-api.d.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@angular/common": "22.2.6",
"@angular/core": "22.2.6",
"rxjs": "7.8.2"
},
"devDependencies": {
"typescript": "6.0.3"
}
}
The generated file contains a components type; the Angular service takes aliases from it and passes them to HttpClient:
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import type { components } from './bookstore-api';
export type BookDto = components['schemas']['BookDto'];
export type OrderDto = components['schemas']['OrderDto'];
@Injectable({ providedIn: 'root' })
export class BookApi {
private readonly http = inject(HttpClient);
search(term: string): Observable<BookDto[]> {
return this.http.get<BookDto[]>('/api/books', { params: { search: term } });
}
order(id: number): Observable<OrderDto> {
return this.http.get<OrderDto>(`/api/orders/${id}`);
}
}
export function statusLabel(order: OrderDto): string {
switch (order.status) {
case 'Placed':
return 'Order placed';
case 'Cancelled':
return 'Order cancelled';
default: {
// If a new status is added, compilation stops here
const unhandled: never = order.status;
return unhandled;
}
}
}
Hand-written types can do the same job; the difference is who catches the drift. A hand-written interface BookDto keeps compiling when a field is removed from the API and shows the bug in the browser as undefined. A type generated from the document changes with the next npm run api:types, and tsc stops on every line that uses that field. Component and service tests are the subject of the Frontend Testing series; what we care about here is checking the contract at compile time.
Classifying Breaking Changes
To see which change would break the client, I made five changes to the API together: I removed Stock from BookDto, added Shipped to OrderStatus, changed OrderDto.Total from decimal to string, added string? CouponCode to PlaceOrderRequest, and added an optional inStock query parameter to the book list. I compared the old and new documents with oasdiff (I removed the repeated endpoints from the output):
$ oasdiff breaking contract-base.json contracts/Bookstore.Api.json
10 changes: 10 error, 0 warning, 0 info
error [response-required-property-removed] at contracts/Bookstore.Api.json
in API GET /api/books
removed the required property `items/stock` from the response with the `200` status
error [new-required-request-property] at contracts/Bookstore.Api.json
in API POST /api/orders
added the new required request property `couponCode`
error [response-property-enum-value-added] at contracts/Bookstore.Api.json
in API POST /api/orders
added the new `Shipped` enum value to the `status` response property for the response status `201`
The server may now return a value the previous contract excluded, so a client written against it may not handle the response. If the value set is meant to grow, declare it with x-extensible-enum.
error [response-property-type-changed] at contracts/Bookstore.Api.json
in API POST /api/orders
the `total` response's property `type/format` changed from `number/double` to `string` for status `201`
oasdiff changelog showed two more info lines for the same comparison: the inStock parameter appeared as "new-optional-request-parameter", and it reminded me that info.version was still 1.0.0 despite a breaking change. The logic of the classification is the direction in which data flows:
| Change | Direction | Result | Why |
|---|---|---|---|
| Removing a field | response | breaking | the client may be reading that field |
| Changing a type | response | breaking | the type the client parses no longer arrives |
| Adding an enum value | response | breaking | the client receives a value it doesn't know |
| Adding a required field | request | breaking | the old client doesn't send that field |
| Adding an optional parameter | request | safe | the old client's request stays valid |
| Adding a field | response | usually safe | provided the client ignores unknown fields |
When the new types were generated and tsc ran, the Angular side showed its counterpart:
src/app/catalog/labels.ts(4,15): error TS2339: Property 'stock' does not exist on type '{ id: number; isbn: string; title: string; price: number; }'.
src/app/catalog/labels.ts(4,35): error TS2339: Property 'stock' does not exist on type '{ id: number; isbn: string; title: string; price: number; }'.
src/app/catalog/labels.ts(14,13): error TS2322: Type '"Shipped"' is not assignable to type 'never'.
There was no error for total turning into a string, because no line in this small Angular code uses total. The compiler only protects the fields that are used; oasdiff protects the whole contract.
Catching It in CI
I put the three checks into a single script; CI runs it on pull requests against the contract on the base branch (the file extracted with git show origin/main:contracts/Bookstore.Api.json):
#!/usr/bin/env bash
# Usage: scripts/check-contract.sh <base-contract.json>
set -euo pipefail
base="$1"
# 1. Build regenerates contracts/Bookstore.Api.json; it must already be committed
dotnet build src/Bookstore.Api -c Release --nologo -v quiet
git diff --exit-code -- contracts/Bookstore.Api.json
# 2. Compared with the base branch: no breaking change allowed
oasdiff breaking "$base" contracts/Bookstore.Api.json --fail-on ERR
# 3. Angular types are regenerated from the contract and the code must still compile
(cd web && npm run --silent api:types && npm run --silent typecheck)
echo "contract OK"
With no changes the script ended with contract OK. When I removed Stock from BookDto and ran it without committing the contract, it stopped at the first step and showed the diff:
@@ -229,8 +229,7 @@
"id",
"isbn",
"title",
- "price",
- "stock"
+ "price"
],
Once the contract was committed, the second step kicked in; oasdiff reported response-required-property-removed for three endpoints and, thanks to --fail-on ERR, the script ended with exit code 1. That flag matters: when I ran the same comparison without it, oasdiff listed ten errors but the exit code was 0; the pipeline would have stayed green. Setting up the Angular step on GitHub Actions is covered in the GitHub Actions series post "Angular CI with GitHub Actions: setup-node, npm ci, Lint, Vitest, Build Budgets and Playwright".
When Do You Need Pact?
The approach in this post is provider-driven: the API writes the contract, the client follows it, and the tools check whether the contract stays backward compatible. oasdiff tells you a field was removed but doesn't know who actually uses it; if no screen uses stock, this "breaking change" breaks nobody.
Consumer-driven contract testing (Pact) turns this question around: each consumer writes the requests and responses it expects from the provider into a contract file, and the provider verifies those files in its own tests. Its mechanics and use between services were covered in the Microservices series post "Service Contracts and Versioning: Expand-Contract, Tolerant Reader and Consumer-Driven Contract Tests"; I won't repeat them here. Between Angular and the API I only need Pact in these cases: when the API is used by several teams or several clients (web, mobile, partner integrations) and changes can't be made without knowing who uses which field; or when the client and the API have separate deployment schedules. For a single Angular app and an API living in the same repo, the build-time document, oasdiff and tsc together need less maintenance and catch the same bugs.
In Production
Manage breaking changes instead of forbidding them. If a field has to go, first mark it deprecated in the document, let the Angular side stop using it, and remove it in a later release; the expand-contract steps are in the Microservices series post. oasdiff's "api-version-not-bumped" info helps here too: if a breaking change is made on purpose, the API version should change as well.
If enums will grow, say so in the contract. oasdiff's message treats a new enum value as breaking and suggests the x-extensible-enum extension for value sets that are expected to grow. On the Angular side, the counterpart is writing a default display for unknown statuses instead of a never check. Which one you choose determines the deployment order for a new order status: does the client get updated first, or the API?
Money amounts appear in the document as "type": "number", "format": "double"; the precision of decimal is not visible in the contract. If Angular does more than display amounts (cart totals, discounts), this difference matters; keep the calculation in the API, or decide from the start to carry amounts as strings in the contract. Changing it later falls into the type-change row of the table above.
Common Pitfalls
Taking the OpenAPI document as a guarantee of real behavior. The string? CouponCode in the classification experiment went into the document as required because it is a record constructor parameter, and oasdiff reported it as "new-required-request-property". But the running API accepted a request that didn't send the field at all with 201; System.Text.Json does not enforce constructor parameters as required by default. The document said one thing and the behavior another. When I gave the parameter a default value (string? CouponCode = null), the field left the required list and oasdiff's error count dropped from ten to nine. If you treat the document as the single source of truth, at least one integration test should actually try a claim the document makes: does a field marked required produce 400 when missing, and does a field marked optional produce 200 when missing?
Mixing up nullable and required. OrderDto.TrackingNumber is both required and ["null", "string"] in the document: the field is always present in the response but its value may be null. In TypeScript that means trackingNumber: null | string (that is what the generated file has), not trackingNumber?: string. That distinction decides between ?. and ?? in Angular code; if the document is wrong, the types are wrong too.
Fixing the generated file by hand. bookstore-api.d.ts is rewritten from scratch on every api:types run. If a type comes out wrong, the place to fix it is the C# side or an OpenAPI transformer; a manual fix in the generated file silently disappears on the next build.
Reading the contract only from the running app. Getting /openapi/v1.json in CI requires starting the API, and environment settings (such as endpoints enabled in Development) can change the document. The file generated at build time and committed guarantees everyone looks at the same thing in a pull request.
A Small Experiment
★☆☆ Change the Stock field in CreateBookRequest to int? Stock = null. Find the change to the document in the diff and see which category oasdiff puts it in.
★★☆ Add an integration test to Bookstore that compares the running API's /openapi/v1.json response with contracts/Bookstore.Api.json. Is there a difference between the two documents? If so, why?
★★★ Write an OpenAPI schema transformer that adds the x-extensible-enum extension to OrderStatus. Add the Shipped value again and compare oasdiff's result, then replace the never check on the Angular side with a default display for unknown statuses.
The boundaries where the API faces both the outside world and Angular are now under test. The next post moves into the E2E block: bringing the browser in with Playwright, locators, auto-waiting and the trace viewer.