It all started with a tweet from Mathieu Riegler of the Angular team about an early demo of a new HttpResource. This potentially new experimental HttpResource provides a functional approach to the HTTP client (using the HTTP client under the hood) and makes use of signals (PR) released in version 19.2.

That tweet got me thinking: what Angular really lacks is full type safety. My idea was schema-driven type safety to ensure that the developer experience is cleaner. It would also mean that any developer starting out on the project could let their IDE tell them the endpoints with any parameters, queries, and body. Upon this suggestion, Alex from the Angular team stated that you can simply use the map function provided in the experimental HttpResource.

The solution mentioned is good; it makes the request body type-safe. However, it is not the “full” type safety I was aiming for. I wanted the IDE to have knowledge of every route, every possible parameter or query that could be used, and the exact structure of the body in a POST request. Essentially, something akin to “Better Fetch” but tailored for Angular.

Why Is This Important?

Let’s look at what other projects are doing in this space. Most full-stack frameworks in other languages have started implementing end-to-end type safety. For example, Analog integrates libraries such as tRPC to provide seamless communication between the frontend and backend.

Modern backend frameworks like Hono HC, JStack, and ElysiaJS Eden all come with fully type-safe clients for the user interface.

The issue is that these solutions are all wrappers around fetch or axios. Angular, on the other hand, has a powerful built-in HTTP client but lacks type safety. Additionally, Angular is not a full-stack framework, meaning it does not inherently provide an end-to-end type-safe solution.

To bridge this gap, we need a way to make the Angular HTTP client type-safe. By mapping backend definitions or OpenAPI specifications to our type system, we can achieve full end-to-end type safety—without being confined to a specific framework.

The Requirements

First I created a list of requirements:

  • Must be able to use schema and type inference
  • Must understand all the routes available
  • Must have the same features as the Angular HTTP Client
  • Must use the HTTP Client or support the HTTP Client interceptors design
  • Must support, or be close to, Angular HTTP Resource
  • It should be possible to navigate to the schema from its usage
  • Must support Standard Schema so that we are not forcing a dependency and users can bring their own

Getting Started

My starting point in this process was not to dive straight into code—I did not mind if it produced messy code at first. My focus was on improving the developer experience. I wanted to plan from the perspective of the API/development usage; the goal is to make it as simple as possible.

How can a developer use modern Angular to inject our type-safe HTTP client? This led me to start with Dependency Injection.

Out of the box, Angular HTTP uses provideHttpClient, which accepts HttpFeature[] (an Environment Provider). This meant I wanted this to be hidden.

export function provideTypedHttp(
  ...features: HttpFeature<HttpFeatureKind>[]
): EnvironmentProviders[] {
  return [provideHttpClient(...features)];
}

I also wanted developers to be able to inject the code. I was aware that we would have to provide the types, so it was likely we would create a const somewhere that passes in the schema. I came up with two options:

export function injectTypedHttpClient() {
  return inject();
}

export function createGlobalTypedHttpClient() {
  return () => injectTypedHttpClient();
}

The idea is that people can use injectTypedHttpClient and provide the typing for each type, or someone could do

const injectTypedHttp = createGlobalTypedHttpClient();

and then, in components or services, simply call your const injectTypedHttp() and it will already have the typing.

Designing the API Definitions

This is where it gets a little more complex: we need to decide on the API schema.

import type { StandardSchemaV1 } from '@standard-schema/spec';

type StringKeyOf<T> = Extract<keyof T, string>;

export type HttpMethod =
  | 'GET'
  | 'POST'
  | 'PUT'
  | 'PATCH'
  | 'DELETE'
  | 'HEAD'
  | 'OPTIONS';
type RouteWithMethod = `@${HttpMethod}/${string}`;
type RouteWithMethodOrString = RouteWithMethod | (string & {});

export type ExtractHTTPMethod<Route extends string> =
  Route extends `@${infer M}/${string}`
    ? M extends HttpMethod
      ? M
      : never
    : never;

export type EndpointDefinition<
  Input = unknown,
  Output = unknown,
  Query = unknown,
  Params = unknown
> = {
  input?: StandardSchemaV1<Input, Input> | Input;
  output?: StandardSchemaV1<unknown, Output> | Output;
  query?: StandardSchemaV1<Query, Query> | Query;
  params?: StandardSchemaV1<Params, Params> | Params;
};

export type ApiSchema<
  T extends Record<string, EndpointDefinition> = Record<
    RouteWithMethodOrString,
    EndpointDefinition
  >
> = T;

export type RouteKey<TSchema extends ApiSchema> = StringKeyOf<TSchema>;

Working with types can be confusing, so I will do my best to explain and add notes about how it could be improved.

The part employs TypeScript generics. It makes EndpointDefinition flexible so that it can work with different data types for each API endpoint.

  • Input — If an input is provided, then it is either a POST or PUT request. The input definition relates to the body that is being sent with the request.
  • Output — Output is the HTTP response that you will get back from the server.
  • Query — Query parameters (e.g., ?search=hello in a URL). In our case, you would define it using an object or something like z.object in Zod.
  • Params — Route parameters (e.g., /users/:id).

Since we want to be able to declare the full available API, we use Record. This allows us to use an object approach for our definition.

  • RouteKeyRouteKey is a list of all the string keys from Record. If we used keyof instead, then the type would be string | number | symbol; this is why I use an extract to only get the string keys.

Examples

Purely types:

export type GreetingOutput = {
  message: string;
};

export type GetUserParams = {
  id: number;
};

export type UserOutput = {
  id: number;
  name: string;
};

export type ExampleAPI = ApiSchema<{
  '/greeting': {
    output: GreetingOutput;
  };
  '/user/:id': {
    params: GetUserParams;
    output: UserOutput;
  };
}>;

Using Zod (or any supported standard schema):

import { z } from 'zod';

const GreetingOutputSchema = z.object({
  message: z.string(),
});

const GetUserParamsSchema = z.object({
  id: z.number(),
});

const UserOutputSchema = z.object({
  id: z.number(),
  name: z.string(),
});

export const apiSchema = {
  '/greeting': {
    output: GreetingOutputSchema,
  },
  '/user/:id': {
    params: GetUserParamsSchema,
    output: UserOutputSchema,
  },
} as const;

Notice that in Zod we use as const because Zod provides the typing for you, and Zod is not a type but a runtime type-checking schema.

Extracting the Types from the Definition

So that we can inform TypeScript of the actual typing and reference it in areas of the code, I have created inference helpers to abstract key properties from the EndpointDefinition:

import type { StandardSchemaV1 } from '@standard-schema/spec';

export type InferInput<E> = E extends { input: StandardSchemaV1 }
  ? StandardSchemaV1.InferInput<E['input']>
  : E extends { input: infer I }
  ? I
  : unknown;

export type InferOutput<E> = E extends { output: StandardSchemaV1 }
  ? StandardSchemaV1.InferOutput<E['output']>
  : E extends { output: infer O }
  ? O
  : unknown;

export type InferQuery<E> = E extends { query: StandardSchemaV1 }
  ? StandardSchemaV1.InferInput<E['query']>
  : E extends { query: infer Q }
  ? Q
  : unknown;

export type InferParams<E> = E extends { params: StandardSchemaV1 }
  ? StandardSchemaV1.InferInput<E['params']>
  : E extends { params: infer P }
  ? P
  : undefined;

These utility types extract the expected input, output, query, and params types from an endpoint definition by using StandardSchemaV1’s helpers if available, or by falling back to the directly provided types by the user. If the definition does not contain a value, then it finally defaults to unknown.

Let’s Improve Our API for Using a Type-Safe HTTP Client

Previously, we created the boilerplate for dependency injection. The next step is to supply the necessary types and options to build our API.

export interface TypedHttpClientOptions<S extends ApiSchema> {
  baseURL: string;
  schema: S;
  injector?: Injector;
}

Our type-safe HTTP client should recognise the base URL and the Angular injector if you prefer not to use functional injection. If you are not using Zod, you still need to provide the schema; however, you may simply pass an empty update and specify the type as a string.

Both the Angular HTTP Client and the experimental HttpResource expose an API. We can create our own strongly typed options for requests to ensure they are type-safe.

import { EndpointDefinition } from './api-schema';
import { InferInput, InferParams, InferQuery } from './inference-helpers';
import { HttpHeaders } from '@angular/common/http';

export interface HttpTypedOptions<E extends EndpointDefinition> {
  body?: InferInput<E>;
  query?: InferQuery<E>;
  params?: InferParams<E>;
  method?: HttpMethod;
  headers?: HttpHeaders | Record<string, string | string[]>;
}

We will now go ahead and create the outline for the type-safe HTTP client:

export class TypedHttpClient<TSchema extends ApiSchema> {
  private http: HttpClient;
  private typedOptions: TypedHttpClientOptions<TSchema>;

  constructor(
    typedOptions: TypedHttpClientOptions<TSchema>,
    injector?: Injector
  ) {
    this.typedOptions = typedOptions;
    const inj = injector || typedOptions.injector || inject(Injector);
    this.http = inj.get(HttpClient);
  }

  request<Route extends RouteKey<TSchema>>(
    route: Route,
    fetchOptions?: HttpTypedOptions<TSchema[Route]>
  ): Observable<InferOutput<TSchema[Route]>> {}

  get<Route extends RouteKey<TSchema>>(
    route: Route,
    options?: HttpTypedOptions<TSchema[Route]>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'GET' });
  }

  post<Route extends RouteKey<TSchema>>(
    route: Route,
    body: InferInput<TSchema[Route]>,
    options?: Omit<HttpTypedOptions<TSchema[Route]>, 'body' | 'method'>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'POST', body });
  }

  put<Route extends RouteKey<TSchema>>(
    route: Route,
    body: InferInput<TSchema[Route]>,
    options?: Omit<HttpTypedOptions<TSchema[Route]>, 'body' | 'method'>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'PUT', body });
  }

  patch<Route extends RouteKey<TSchema>>(
    route: Route,
    body: InferInput<TSchema[Route]>,
    options?: Omit<HttpTypedOptions<TSchema[Route]>, 'body' | 'method'>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'PATCH', body });
  }

  delete<Route extends RouteKey<TSchema>>(
    route: Route,
    options?: HttpTypedOptions<TSchema[Route]>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'DELETE' });
  }

  head<Route extends RouteKey<TSchema>>(
    route: Route,
    options?: HttpTypedOptions<TSchema[Route]>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'HEAD' });
  }

  options<Route extends RouteKey<TSchema>>(
    route: Route,
    options?: HttpTypedOptions<TSchema[Route]>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'OPTIONS' });
  }
}

Without actually implementing the internal logic of the request, we can already outline the remainder of the code. By extending RouteKey in each method, TypeScript will be able to inform the user of the available routes.

Note: This version shows all options for every endpoint. In future releases, we could limit the options so that each endpoint only exposes what is actually available.

Now that we have an outline of everything we need, we can go ahead and edit out the DX API so that it functions (except for actually making the request).

export const TYPED_HTTP_CLIENT_OPTIONS = new InjectionToken<
  TypedHttpClientOptions<any>
>('TYPED_HTTP_CLIENT_OPTIONS');

export const TYPED_HTTP_CLIENT = new InjectionToken<TypedHttpClient<any>>(
  'TYPED_HTTP_CLIENT'
);

export function provideTypedHttp<S extends ApiSchema>(
  options: TypedHttpClientOptions<S>
): Provider[] {
  return [
    { provide: TYPED_HTTP_CLIENT_OPTIONS, useValue: options },
    {
      provide: TYPED_HTTP_CLIENT,
      useFactory: typedHttpClientFactory,
      deps: [TYPED_HTTP_CLIENT_OPTIONS, Injector],
    },
  ];
}

export function typedHttpClientFactory<S extends ApiSchema>(
  options: TypedHttpClientOptions<S>,
  injector: Injector
): TypedHttpClient<S> {
  return new TypedHttpClient(options, injector);
}

export function injectTypedHttpClient<S extends ApiSchema>():
  TypedHttpClient<S> {
  return inject(TYPED_HTTP_CLIENT) as TypedHttpClient<S>;
}

export function createGlobalTypedHttpClient<S extends ApiSchema>():
  () => TypedHttpClient<S> {
  return () => injectTypedHttpClient<S>();
}

The updated code now includes all the key type definitions needed to ensure type safety. We are now injecting the options into our TypedHttpClient, and our dependency injection returns a new, strongly typed instance of TypedHttpClient.

Building the HTTP Request

Note: In the implementation you will see code that is implemented but does not yet have the full typing support which would allow us to infer much more. Based on our implementation, we can further enhance the typing. While it is not currently expressed in the type definitions, it demonstrates key runtime logic for:

  • Inferring parameter types from the route via a naming convention such as key::{type}.
  • Automatically detecting the HTTP method from the route declaration (e.g., using @POST), thereby allowing the method to be inferred without explicitly passing it in.

In future iterations, we could refine our type definitions so that the TypeScript compiler can infer these details—such as the request method and parameter types—directly from the route string. For now, the implementation shows how these runtime deductions are made, even though the current typings do not fully cover these scenarios. This incremental approach keeps the API clear from the outset while leaving room to further enhance type inference as the implementation evolves.

With both the HTTP Resource (more on that later) and the HTTP Client, we need to build the HTTP Resource request. In the upcoming PR for HTTP Resource, Angular has separated types and exposed a new type called HttpResourceRequest. If this is unavailable at the time of reading, you can take the following interface, which is extracted from the PR:

/**
 * @license Angular v19.2.0-next.1
 * (c) 2010-2024 Google LLC. https://angular.io/
 * License: MIT
 */

import type { HttpHeaders, HttpParams } from '@angular/common/http';

/**
 * The structure of an `httpResource` request.
 *
 * @experimental
 */
export declare interface HttpResourceRequest {
  url: string;
  method?: string;
  body?: unknown;
  params?: HttpParams | Record<string, string | number | boolean | ReadonlyArray<string | number | boolean>>;
  headers?: HttpHeaders | Record<string, string | ReadonlyArray<string>>;
  reportProgress?: boolean;
  withCredentials?: boolean;
  transferCache?: {
    includeHeaders?: string[];
  } | boolean;
}

Now onto building the Http Resource Request that will be fully type-safe. Let’s start with defining the API:

export function buildHttpRequest<
  Schema extends ApiSchema,
  Route extends RouteKey<Schema>,
  Def extends EndpointDefinition = Schema[Route]
>(
  baseURL: string,
  route: Route,
  endpoint: Def,
  fetchOptions?: HttpTypedOptions<Def>
): HttpResourceRequest {

This approach is highly generic; rather than only accepting an EndpointDefinition, it makes more sense to provide the entire schema. This way, the EndpointDefinition becomes strongly typed according to the route provided. In other words, you never need to pass in the endpoint definition explicitly—just the schema and the route suffice. Admittedly, from a developer experience perspective (internally building the library), this might be slightly inconvenient, as you are forced to define the route in both the type and as a parameter. However, this method guarantees that we always work with the exact definition we intend.

First, we need to understand which method we are using. As outlined in the note, we support additional route standards beyond what the types might initially suggest. We should support an @Method prefix and use this to set the HTTP request method. We will also need to remove it from the route. If this is not supplied, do not worry, because we already know whether to use POST or GET based on whether an input is defined in the EndpointDefinition.

let method: string | undefined = fetchOptions?.method;
  let actualRoute = route;
  const methodRegex = /^@(\w+)\//;
  const modifierMatch = actualRoute.match(methodRegex);
  if (modifierMatch) {
    method = modifierMatch[1].toUpperCase();
    actualRoute = actualRoute.replace(methodRegex, '/') as Route;
  }
  if (!method) {
    method = endpoint.input ? 'POST' : 'GET';
  }

Next, we need to merge the routes with their parameters. In case you were not already aware, the route can be in the following format: user/:id::number/edit. While :{param} is required for parameters, the ::{type} is not a strict requirement for now but might be used in future versions to improve type inference. This means that, in future, we will not need to enforce a separate parameter merge, as the type will correctly infer the string format as "user/number/edit". For the HTTP Resource, we also need to support signals. Therefore, we must check whether a parameter is a signal and, if so, call the signal to retrieve its value.

let url = baseURL + actualRoute;
if (fetchOptions?.params) {
  for (const [key, value] of Object.entries(
    fetchOptions.params as Record<
      string,
      string | number | Signal<number> | Signal<string>
    >
  )) {
    const pattern = new RegExp(`:${key}(?:::[a-zA-Z]+)?`);
    url = url.replace(
      pattern,
      encodeURIComponent(isSignal(value) ? value() : String(value))
    );
  }
}

Next, we need to migrate the query into an instance of HttpParams and the headers into HttpHeaders.

let params: HttpParams | undefined;
if (fetchOptions?.query) {
  params = new HttpParams({
    fromObject: fetchOptions.query as Record<string, string>,
  });
}

let headers: HttpHeaders | undefined;
if (fetchOptions?.headers) {
  headers =
    fetchOptions.headers instanceof HttpHeaders
      ? fetchOptions.headers
      : new HttpHeaders(
          fetchOptions.headers as Record<string, string | string[]>
        );
}

The following part is optional. If you are using a schema, we can validate that the schema is correct (note that promise-based schemas are not yet supported) and then return the values needed for an HttpResourceRequest.

let body = fetchOptions?.body;
if (endpoint.input && (endpoint.input as any)['~standard']) {
  let result = (endpoint.input as any)['~standard'].validate(body);
  if (result instanceof Promise) {
    throw new Error('Async validation not supported in this helper.');
  }
  if ('issues' in result) {
    throw new Error(
      `Request body validation failed: ${JSON.stringify(result.issues, null, 2)}`
    );
  }
  body = result.value;
}
// Return the HttpResourceRequest.
return {
  url,
  method,
  body,
  params,
  headers,
};

So, to recap, here is the complete code for buildHttpRequest:

import {
  HttpHeaders,
  HttpParams,
  HttpResourceRequest,
} from '@angular/common/http';
import { isSignal, Signal } from '@angular/core';
import type { EndpointDefinition } from './api-schema';
import type { HttpTypedOptions } from './type';

export function buildHttpRequest<Def extends EndpointDefinition>(
  baseURL: string,
  route: string,
  endpoint: Def,
  fetchOptions?: HttpTypedOptions<Def>
): HttpResourceRequest {
  let method: string | undefined = fetchOptions?.method;
  let actualRoute = route;
  const methodRegex = /^@(\w+)\//;
  const modifierMatch = actualRoute.match(methodRegex);
  if (modifierMatch) {
    method = modifierMatch[1].toUpperCase();
    actualRoute = actualRoute.replace(methodRegex, '/');
  }
  if (!method) {
    method = endpoint.input ? 'POST' : 'GET';
  }

  let url = baseURL + actualRoute;
  if (fetchOptions?.params) {
    for (const [key, value] of Object.entries(
      fetchOptions.params as Record<
        string,
        string | number | Signal<number> | Signal<string>
      >
    )) {
      const pattern = new RegExp(`:${key}(?:::[a-zA-Z]+)?`);
      url = url.replace(
        pattern,
        encodeURIComponent(isSignal(value) ? value() : String(value))
      );
    }
  }

  let params: HttpParams | undefined;
  if (fetchOptions?.query) {
    params = new HttpParams({
      fromObject: fetchOptions.query as Record<string, string>,
    });
  }

  let headers: HttpHeaders | undefined;
  if (fetchOptions?.headers) {
    headers =
      fetchOptions.headers instanceof HttpHeaders
        ? fetchOptions.headers
        : new HttpHeaders(
            fetchOptions.headers as Record<string, string | string[]>
          );
  }

  let body = fetchOptions?.body;
  if (endpoint.input && (endpoint.input as any)['~standard']) {
    let result = (endpoint.input as any)['~standard'].validate(body);
    if (result instanceof Promise) {
      throw new Error('Async validation not supported in this helper.');
    }
    if ('issues' in result) {
      throw new Error(
        `Request body validation failed: ${JSON.stringify(result.issues, null, 2)}`
      );
    }
    body = result.value;
  }
  return {
    url,
    method,
    body,
    params,
    headers,
  };
}

Finishing the TypedHttpClient

Previously, we implemented the TypedHttpClient, with the only exception being the request method. Let’s take a look at where we left off.

request<Route extends RouteKey<TSchema>>(
  route: Route,
  fetchOptions?: HttpTypedOptions<TSchema[Route]>
): Observable<InferOutput<TSchema[Route]>> {
}

First things first, we need to verify that the endpoint exists. Only then do we proceed to build the HttpResourceRequest.

const endpoint: EndpointDefinition =
  this.typedOptions.schema[route as string];
if (!endpoint) {
  throw new Error(`Route "${route}" is not defined in the API schema.`);
}

const req: HttpResourceRequest = buildHttpRequest(
  this.typedOptions.baseURL,
  route,
  endpoint,
  fetchOptions
);

After verifying that the endpoint exists and building the HttpResourceRequest, we must make the API call using Angular's HTTP client so that interceptors are still supported. In addition, if there is a standard schema for the output, we should validate it and throw an error if there is a mistake.

return this.http
  .request<InferOutput<TSchema[Route]>>(
    req.method! satisfies string,
    req.url,
    {
      body: req.body,
      headers: req.headers as any,
      params: req.params,
      responseType: 'json',
    }
  )
  .pipe(
    switchMap((res) => {
      if (endpoint.output && (endpoint.output as any)['~standard']) {
        const result = (endpoint.output as any)['~standard'].validate(res);
        return from(Promise.resolve(result)).pipe(
          switchMap((validated) => {
            if ('issues' in validated) {
              throw new Error(
                `Request body validation failed: ${JSON.stringify(
                  validated.issues,
                  null,
                  2
                )}`
              );
            }
            return of(res);
          })
        );
      }
      return of(res);
    })
  );

So, to summarise, here is the complete code for TypedHttpClient:

export class TypedHttpClient<TSchema extends ApiSchema> {
  private http: HttpClient;
  private typedOptions: TypedHttpClientOptions<TSchema>;

  constructor(
    typedOptions: TypedHttpClientOptions<TSchema>,
    injector?: Injector
  ) {
    this.typedOptions = typedOptions;
    const inj = injector || typedOptions.injector || inject(Injector);
    this.http = inj.get(HttpClient);
  }

  request<Route extends RouteKey<TSchema>>(
    route: Route,
    fetchOptions?: HttpTypedOptions<TSchema[Route]>
  ): Observable<InferOutput<TSchema[Route]>> {
    const endpoint: EndpointDefinition =
      this.typedOptions.schema[route as string];
    if (!endpoint) {
      throw new Error(`Route "${route}" is not defined in the API schema.`);
    }
    const req: HttpResourceRequest = buildHttpRequest(
      this.typedOptions.baseURL,
      route,
      endpoint,
      fetchOptions
    );
    return this.http
      .request<InferOutput<TSchema[Route]>>(
        req.method! satisfies string,
        req.url,
        {
          body: req.body,
          headers: req.headers as any,
          params: req.params,
          responseType: 'json',
        }
      )
      .pipe(
        switchMap((res) => {
          if (endpoint.output && (endpoint.output as any)['~standard']) {
            const result = (endpoint.output as any)['~standard'].validate(res);
            return from(Promise.resolve(result)).pipe(
              switchMap((validated) => {
                if ('issues' in validated) {
                  throw new Error(
                    `Request body validation failed: ${JSON.stringify(
                      validated.issues,
                      null,
                      2
                    )}`
                  );
                }
                return of(res);
              })
            );
          }
          return of(res);
        })
      );
  }

  get<Route extends RouteKey<TSchema>>(
    route: Route,
    options?: HttpTypedOptions<TSchema[Route]>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'GET' });
  }

  post<Route extends RouteKey<TSchema>>(
    route: Route,
    body: InferInput<TSchema[Route]>,
    options?: Omit<HttpTypedOptions<TSchema[Route]>, 'body' | 'method'>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'POST', body });
  }

  put<Route extends RouteKey<TSchema>>(
    route: Route,
    body: InferInput<TSchema[Route]>,
    options?: Omit<HttpTypedOptions<TSchema[Route]>, 'body' | 'method'>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'PUT', body });
  }

  patch<Route extends RouteKey<TSchema>>(
    route: Route,
    body: InferInput<TSchema[Route]>,
    options?: Omit<HttpTypedOptions<TSchema[Route]>, 'body' | 'method'>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'PATCH', body });
  }

  delete<Route extends RouteKey<TSchema>>(
    route: Route,
    options?: HttpTypedOptions<TSchema[Route]>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'DELETE' });
  }

  head<Route extends RouteKey<TSchema>>(
    route: Route,
    options?: HttpTypedOptions<TSchema[Route]>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'HEAD' });
  }

  options<Route extends RouteKey<TSchema>>(
    route: Route,
    options?: HttpTypedOptions<TSchema[Route]>
  ): Observable<InferOutput<TSchema[Route]>> {
    return this.request(route, { ...options, method: 'OPTIONS' });
  }
}

Bonus: Typed HTTP Resource

As mentioned at the top of the article, this all started from a post about Angular HttpResource. Needless to say, we should implement a basic form of HttpResource. This implementation should be signal-based, support multiple request types, and accommodate the same schema as our TypedHttpClient.

From an API perspective, we want it to function like our createGlobalTypedHttpClient, where it returns the actual injector function that can be used within services and components. We want the API to operate as follows:

const httpResource = createTypedHttpResource({
  baseURL: 'https://pokeapi.co/api/v2',
  schema: pokeApiSchema,
});

export class AppComponent {
  id = signal(1);
  resource = httpResource('/pokemon/:id/', { params: { id: this.id } });
}

Unlike the TypedHttpClient, we do not need anything fancy here. We should only need to use the standard provideHttpClient to get this working. Below is the complete code for our createTypedHttpResource:

import { inject, Injector } from '@angular/core';
import {
  httpResource,
  HttpResource,
  HttpResourceFn,
} from '@angular/common/http';
import type { ApiSchema, EndpointDefinition, RouteKey } from './api-schema';
import {
  InferInput,
  InferOutput,
  InferQuery,
  InferParams,
} from './inference-helpers';
import type { StandardSchemaV1 } from '@standard-schema/spec';
import type { HttpTypedOptions } from './type';
import { buildHttpRequest } from './build-http-request';
import type { Signal } from '@angular/core';

export type MakeNestedSignal<T> = T extends any[]
  ? { [K in keyof T]: MakeNestedSignal<T[K]> }
  : T extends object
  ? {
      [K in keyof T]:
        | MakeNestedSignal<T[K]>
        | Signal<MakeNestedSignal<T[K]>>;
    }
  : T | Signal<T>;

export type TypedResourceOptions<E extends EndpointDefinition> = {
  body?: InferOutput<E>;
  query?: MakeNestedSignal<InferQuery<E>>;
  params?: MakeNestedSignal<InferParams<E>>;
  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
  headers?: Record<string, string | string[]>;
  defaultValue?: InferOutput<E['output']>;
};

export interface TypedHttpResourceClientOptions<S extends ApiSchema> {
  baseURL: string;
  schema: S;
  injector?: Injector;
}

export function createTypedHttpResource<TSchema extends ApiSchema>(
  options: TypedHttpResourceClientOptions<TSchema>
) {
  return function <Route extends RouteKey<TSchema>>(
    route: Route,
    opts?: TypedResourceOptions<TSchema[Route]>
  ): HttpResource<InferOutput<TSchema[Route]>> {
    const injector = options.injector ?? inject(Injector);
    const endpoint: EndpointDefinition = options.schema[route];
    if (!endpoint) {
      throw new Error(
        `Route "${String(route)}" is not defined in the API schema.`
      );
    }

    // Define a mapping function to process the response.
    const mapResponse = (response: unknown): InferOutput<
      typeof endpoint
    > => {
      if (
        endpoint.output &&
        typeof endpoint.output === 'object' &&
        '~standard' in endpoint.output
      ) {
        let result = (endpoint.output as StandardSchemaV1)['~standard'].validate(
          response
        );
        if (result instanceof Promise) {
          throw new Error('Async response validation not supported.');
        }
        if ('issues' in result) {
          throw new Error(
            `Response validation failed: ${JSON.stringify(result.issues, null, 2)}`
          );
        }
        return result.value;
      }
      return response as InferOutput<typeof endpoint>;
    };

    return httpResource<InferOutput<TSchema[Route]>>(
      () => {
        const req = buildHttpRequest(
          options.baseURL,
          String(route),
          endpoint,
          opts as HttpTypedOptions<any>
        );
        return {
          url: req.url,
          method: req.method,
          body: req.body,
          params: req.params,
          headers: req.headers,
        };
      },
      { defaultValue: opts?.defaultValue, injector, map: mapResponse as any }
    ) as HttpResource<InferOutput<TSchema[Route]>>;
  };
}

Why Was This Created?

The goal of this project is to provide a more developer-friendly way to work with Angular HTTP. The current Angular HTTP client is great, but it lacks the type safety that TypeScript provides. This project aims to enable a more type-safe approach to working with Angular HTTP, making API integration in Angular easier.

In a typical project, you may have 20 or more endpoints to integrate with, alongside direct third-party access. While third-party services often provide SDKs that abstract their internal behaviour, internal projects lack this convenience, requiring developers to search through documentation or read the code to understand what is needed.

With a type-safe HTTP client, your IDE effectively becomes the documentation. You can view endpoints, query parameters such as GetUserParamsSchema, and input/output bodies directly. This eliminates the need for constant reference-checking, allowing developers to understand requirements without trawling through documentation or code.

New developers can jump in and immediately see what they need to do, reducing the need for questions or extensive code reviews.

As of today, I am aware of only one other type-safe HTTP client for Angular. While Analog supports tRPC, the only other solution I know of is a previous package I helped create to support ElysiaJS Eden: "EdenClient for Angular using Elysia Framework". This maps the typings from Elysia and uses a proxy service under the hood. However, this differs from the goal of this article, which is to provide a generic type-safe solution with built-in validation.