GraphQL to
Angular.
Turn a GraphQL SDL schema into typed Angular services. Queries and mutations as Observables over HttpClient, subscriptions over graphql-ws, inject() DI throughout. Unions keep __typename so narrowing works. One file per type, no unused-type bloat. No signup, no install.
type Query {
payment(id: ID!): Payment
payments(limit: Int): [Payment!]!
}
type Mutation {
createPayment(input: CreatePaymentInput!): Payment!
}
type Subscription {
paymentSettled: Payment!
}
enum PaymentStatus {
PENDING
SETTLED
FAILED
}
union PaymentMethod = CardPayment | BankPayment
type Payment {
id: ID!
amount: Float!
currency: String
status: PaymentStatus!
method: PaymentMethod!
}// src/api/services/query.service.ts
import { PAYMENT_QUERY, PAYMENTS_QUERY } from './query-queries';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { BaseApiService } from './base-api.service';
import { Payment } from '../models/payment';
@Injectable()
export class QueryService extends BaseApiService {
public payment(id: string): Observable<Payment | null> {
return this.executeGraphQL<Payment | null>(PAYMENT_QUERY, 'Payment', 'payment', { id });
}
public payments(limit?: number): Observable<Payment[]> {
return this.executeGraphQL<Payment[]>(PAYMENTS_QUERY, 'Payments', 'payments', { limit });
}
}
// src/api/services/subscription.service.ts
import { PAYMENT_SETTLED_QUERY } from './subscription-queries';
@Injectable()
export class SubscriptionService extends BaseApiService {
public paymentSettled(): Observable<Payment> {
return this.subscribeGraphQL<Payment>(PAYMENT_SETTLED_QUERY, 'PaymentSettled', 'paymentSettled');
}
}Idiomatic Angular. Idiomatic TypeScript.
Below: types emitted from a small payments schema with an enum and a union, plus the generated operation document. Each type lands in its own file; the operation keeps __typename on the union so the result is straightforward to narrow.
// src/api/models/payment.ts
import { PaymentMethod } from './payment-method';
import { PaymentStatus } from './payment-status';
export interface Payment {
id: string;
amount: number;
status: PaymentStatus;
method: PaymentMethod;
currency?: string | null;
}
// src/api/models/payment-method.ts
import { CardPayment } from './card-payment';
import { BankPayment } from './bank-payment';
export type PaymentMethod = CardPayment | BankPayment;
// src/api/services/query-queries.ts — the generated
// operation keeps __typename so narrowing works:
export const PAYMENT_QUERY = `query Payment($id: ID!) {
payment(id: $id) {
id
amount
currency
status
method {
__typename
... on CardPayment {
kind
last4
brand
}
... on BankPayment {
kind
iban
}
}
}
}`;Grounded in how the schema reads.
Each claim here is something you can verify by running the converter on your own schema. Click a card to see the schema fragment that triggered it and the code that came out.
union PaymentMethod = CardPayment | BankPayment
type CardPayment {
kind: String!
last4: String!
brand: String!
}
type BankPayment {
kind: String!
iban: String!
}// models/payment-method.ts
import { CardPayment } from './card-payment';
import { BankPayment } from './bank-payment';
export type PaymentMethod = CardPayment | BankPayment;
// emitted operation keeps the __typename selection
// so downstream narrowing works without casts:
//
// method {
// __typename
// ... on CardPayment { kind last4 brand }
// ... on BankPayment { kind iban }
// }Services, not string plumbing.
Every field on Query and Mutation becomes a one-liner method on QueryService or MutationService, returning a typed Observable. The operation documents — selection sets, __typename, inline fragments — are generated constants, so there is no string assembly in your code.
// src/api/services/query.service.ts
import { PAYMENT_QUERY, PAYMENTS_QUERY } from './query-queries';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { BaseApiService } from './base-api.service';
import { Payment } from '../models/payment';
@Injectable()
export class QueryService extends BaseApiService {
public payment(id: string): Observable<Payment | null> {
return this.executeGraphQL<Payment | null>(PAYMENT_QUERY, 'Payment', 'payment', { id });
}
public payments(limit?: number): Observable<Payment[]> {
return this.executeGraphQL<Payment[]>(PAYMENTS_QUERY, 'Payments', 'payments', { limit });
}
}
// src/api/services/mutation.service.ts
import { CREATE_PAYMENT_QUERY } from './mutation-queries';
import { CreatePaymentInput } from '../models/create-payment-input';
@Injectable()
export class MutationService extends BaseApiService {
public createPayment(input: CreatePaymentInput): Observable<Payment> {
return this.executeGraphQL<Payment>(CREATE_PAYMENT_QUERY, 'CreatePayment', 'createPayment', { input });
}
}Or ask Claude to do it.
The same generator ships as a Model Context Protocol server. It calls the same web API this converter uses, so the output is identical. Point Claude Desktop, Cursor, or any MCP-aware agent at a GraphQL schema and it produces the Angular client as a diff — free, no token, no leaving the chat.
// claude-desktop config · .mcp.json
{
"mcpServers": {
"metaengine": {
"command": "npx",
"args": ["-y", "@metaengine/mcp-server"]
}
}
}
// Then in Claude:
// "Read schema.graphql and generate an Angular client
// with typed services into ./src/api."Same generator, as a package.
The converter above is the fastest way to try the output. For CI and repeatable builds, the identical generator ships as a package — pin a version, wire it into your pipeline, and review diffs like any other code.
Things people ask.
The same questions we keep getting. If yours is not here, run the converter — the answer is usually in the output.
.ts files: one interface per object type, one union type per GraphQL union, QueryService / MutationService / SubscriptionService extending a small generated BaseApiService, the operation documents as constants, and BASE_URL / GRAPHQL_WS_CLIENT injection tokens. No GraphQL client library required.Same engine, other stacks.
Every converter runs the same spec-to-IR pipeline. Pick another source format or target stack.
Try it on your own schema.
The converter runs in the browser. Your schema never leaves the page.