0.16.0 • Published 6 months ago
@ls-stack/typed-fetch v0.16.0
Typed Fetch
A strongly-typed fetch wrapper with inferred schema validation and the Result pattern for robust error handling. Compatible with any standardschema.dev library
Features
- Type Safety: Validate request responses against a Standard Schema.
- Result Pattern: Explicit success (
Ok) and failure (Err) handling, eliminating the need for try/catch blocks for expected errors. Uses t-result under the hood. - Detailed Errors: Custom
TypedFetchErrorclass provides comprehensive error information (status, ID, validation issues, etc.). - Flexible Payloads: Supports JSON payloads and multipart/form-data.
- Query Parameters: Easily add simple or JSON-stringified query parameters.
- Customizable Logging: Built-in logging for debugging requests.
- Path Validation: Prevents common path formatting errors.
Installation
npm install @ls-stack/typed-fetch
# or
yarn add @ls-stack/typed-fetchBasic Usage
import { typedFetch, TypedFetchError } from '@ls-stack/typed-fetch';
import { getNodeLogger } from '@ls-stack/typed-fetch/nodeLogger';
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
async function getUser(userId: number): Promise<User | null> {
const result = await typedFetch<User>(`/users/${userId}`, {
host: 'https://api.example.com',
method: 'GET',
responseSchema: UserSchema, // Validate the response
logger: getNodeLogger(), // Optional: Use the built-in Node.js logger or provide your own
});
if (result.ok) {
console.log('User fetched successfully:', result.value);
return result.value;
} else {
// Handle different error types
const error = result.error;
console.error(`Failed to fetch user: ${error.id} - ${error.message}`);
if (error.id === 'response_validation_error') {
console.error('Validation Issues:', error.schemaIssues);
}
// Handle other errors like 'network_or_cors_error', 'request_error', 'invalid_json', etc.
return null;
}
}
getUser(123);API
typedFetch<R = unknown, E = unknown>(path, options)
Makes an HTTP request with type validation and structured error handling.
Overloads:
typedFetch(path: string | URL, options: ApiCallParams & { jsonResponse: false }): Promise<Result<string, TypedFetchError<string>>>typedFetch<R, E>(path: string | URL, options: ApiCallParams<E> & { jsonResponse?: true; responseSchema?: StandardSchemaV1<R>; errorResponseSchema?: StandardSchemaV1<E>; getMessageFromRequestError?: (errorResponse: E) => string; }): Promise<Result<R, TypedFetchError<E>>>
Parameters:
path(string|URL): The request path (relative ifhostis provided, absolute otherwise) or a fullURLobject. Relative paths should not start or end with/.options(object): Configuration for the request.host(string, required ifpathis string): The base URL (e.g.,https://api.example.com).method(HttpMethod):'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'.payload(Record<string, unknown> | unknown[], optional): The JSON payload for methods like POST, PUT, PATCH. Automatically stringified and setsContent-Type: application/json.pathParams(Record<string, string | number | boolean | string[] | number[] | undefined>, optional): Key-value pairs to be added as URL query parameters.jsonPathParams(Record<string, unknown>, optional): Key-value pairs where values are JSON-stringified before being added as query parameters.headers(Record<string, string>, optional): Custom request headers.formData(Record<string, string | File | File[] | RequestPayload | undefined> | FormData, optional): Data formultipart/form-datarequests. Cannot be used withpayload. TheContent-Typeheader is set automatically by the browser. JSON objects within form data will be stringified.responseSchema(StandardSchemaV1<R>, optional): A Standard Schema to validate the successful response body. If provided, theOkresult value will be typed asR.errorResponseSchema(StandardSchemaV1<E>, optional): A Standard Schema to validate the error response body when the request fails (e.g., 4xx, 5xx status). If provided and validation succeeds, theerrResponseproperty ofTypedFetchErrorwill be typed asE.getMessageFromRequestError((errorResponse: E) => string, optional): A function to extract a user-friendly error message from the parsed error response (errResponse). Used whenerrorResponseSchemais provided and validation passes.jsonResponse(boolean, optional): Whether to parse response as JSON. Defaults totrue. Whenfalse, the response will be returned as a string.disablePathValidation(boolean, optional): Disable the validation that prevents paths starting/ending with/.timeoutMs(number, optional): Specifies the timeout for the request in milliseconds. If the request takes longer thantimeoutMs, it will be aborted and result in aTypedFetchErrorwithid: 'timeout'.signal(AbortSignal, optional): AnAbortSignalto allow aborting the request externally. If the request is aborted, it will result in aTypedFetchErrorwithid: 'aborted'.retry(object, optional): Configuration for retrying failed requests.maxRetries(number): The maximum number of times to retry the request.delayMs(number | ((attempt: number) => number)): The delay in milliseconds before the next retry. Can be a fixed number or a function that takes the current retry attempt number (1-indexed) and returns the delay.condition((context: RetryContext<E>) => boolean, optional): A function that receives a context object ({ error: TypedFetchError<E>, retryAttempt: number, errorDuration: number }) and returnstrueif the request should be retried, orfalseotherwise. Defaults to retrying on all retryable errors. Errors withid: 'invalid_options'or'aborted'are never retried.onRetry((context: RetryContext<E>) => void, optional): A function called before a retry attempt. Receives the same context ascondition.
fetcher(TypedFetchFetcher, optional): Custom fetch implementation. Defaults to the globalfetchfunction.responseIsValid((response: { headers: Headers; url: string }) => Error | true, optional): A function to validate the response before processing. Should returntrueif valid, or anErrorif invalid.logger(TypedFetchLogger, optional): Custom logger function for request/response lifecycle logging.
Returns:
Promise<Result<R, TypedFetchError<E>>>: A Promise resolving to aResultobject:Ok<R>: Contains the validated response data (value) if the request and schema validation were successful.Rdefaults tounknownifresponseSchemais not provided.Err<TypedFetchError<E>>: Contains aTypedFetchErrorobject (error) if any error occurred (network, validation, server error, etc.).
TypedFetchError<E = unknown>
Custom error class returned in the Err variant of the Result.
Properties:
id('invalid_options' | 'aborted' | 'network_or_cors_error' | 'request_error' | 'invalid_json' | 'response_validation_error' | 'timeout' | 'invalid_response'): A unique identifier for the type of error.message(string): A description of the error.status(number): The HTTP status code of the response (0 if the request didn't receive a response, e.g., network error).errResponse(E | undefined): The parsed error response body, validated againsterrorResponseSchemaif provided.response(unknown): The raw, unparsed response body (if available).schemaIssues(readonly StandardSchemaV1.Issue[] | undefined): An array of validation issues ifidis'response_validation_error'. Each issue object contains details about the validation failure, such as the path to the invalid field and an error message. (RequiresresponseSchemaorerrorResponseSchemato be provided for the respective validation).cause(unknown): The underlying error object that caused this error (e.g., fromfetchorJSON.parse).payload,pathParams,jsonPathParams,headers,method: Copies of the request parameters for debugging.url(string): The URL that was requested.formData(Record<string, string | File | File[] | RequestPayload | undefined> | FormData | undefined): A copy of the form data payload used in the request, if any.retryAttempt(number | undefined): The retry attempt number if this error occurred during a retry (1-indexed).
Examples
POST Request with JSON Payload
import { typedFetch, TypedFetchError } from '@ls-stack/typed-fetch';
import { z } from 'zod';
const CreateUserSchema = z.object({ name: z.string(), email: z.string() });
const CreatedUserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string(),
});
async function createUser(name: string, email: string) {
const result = await typedFetch<z.infer<typeof CreatedUserSchema>>('/users', {
host: 'https://api.example.com',
method: 'POST',
payload: { name, email }, // Automatically stringified
responseSchema: CreatedUserSchema,
});
result
.onOk((user) => console.log('User created:', user))
.onErr((error) =>
console.error('Failed to create user:', error.id, error.message),
);
}Request with Query Parameters
import { typedFetch } from '@ls-stack/typed-fetch';
import { z } from 'zod';
const SearchParamsSchema = z.object({
query: z.string(),
limit: z.number().optional(),
tags: z.array(z.string()).optional(), // Array will be stringified correctly
});
const SearchResultsSchema = z.array(
z.object({
/* ... result item schema ... */
}),
);
async function searchItems(query: string, limit?: number, tags?: string[]) {
const result = await typedFetch<z.infer<typeof SearchResultsSchema>>(
'/search',
{
host: 'https://api.example.com',
method: 'GET',
pathParams: { query, limit, tags }, // Automatically handles array stringification
responseSchema: SearchResultsSchema,
},
);
// ... handle result ...
}Request with Form Data
import { typedFetch } from '@ls-stack/typed-fetch';
import { z } from 'zod';
const UploadResponseSchema = z.object({ fileUrl: z.string().url() });
async function uploadFile(file: File, metadata: { description: string }) {
const result = await typedFetch<z.infer<typeof UploadResponseSchema>>(
'/upload',
{
host: 'https://api.example.com',
method: 'POST',
formData: {
file: file, // The actual File object
metadata: metadata, // JSON object, will be stringified
otherField: 'some value',
},
responseSchema: UploadResponseSchema,
},
);
// ... handle result ...
}Request with String Response
import { typedFetch } from '@ls-stack/typed-fetch';
async function downloadTextFile() {
const result = await typedFetch('/download/readme.txt', {
host: 'https://api.example.com',
method: 'GET',
jsonResponse: false, // Return response as string instead of parsing JSON
});
if (result.ok) {
console.log('File content:', result.value); // string
} else {
console.error('Download failed:', result.error.message);
}
}Request with Timeout
import { typedFetch } from '@ls-stack/typed-fetch';
import { z } from 'zod';
const DataSchema = z.object({ content: z.string() });
async function fetchDataWithTimeout() {
const result = await typedFetch<z.infer<typeof DataSchema>>(
'/slow-endpoint',
{
host: 'https://api.example.com',
method: 'GET',
responseSchema: DataSchema,
timeoutMs: 5000, // Abort if the request takes longer than 5 seconds
},
);
if (result.ok) {
console.log('Data fetched:', result.value);
} else {
if (result.error.id === 'timeout') {
console.error('Request timed out!', result.error.message);
} else {
console.error(
'Failed to fetch data:',
result.error.id,
result.error.message,
);
}
}
}Request with Automatic Retries
import { typedFetch, TypedFetchError } from '@ls-stack/typed-fetch';
import { z } from 'zod';
const ProductSchema = z.object({ id: z.string(), name: z.string() });
async function getProductWithRetries(productId: string) {
const result = await typedFetch<z.infer<typeof ProductSchema>>(
`/products/${productId}`,
{
host: 'https://api.example.com',
method: 'GET',
responseSchema: ProductSchema,
retry: {
maxRetries: 3,
delayMs: (attempt) => attempt * 1000, // 1s, 2s, 3s delay
condition: (ctx) => {
// Only retry on network errors or 5xx server errors
return (
ctx.error.id === 'network_or_cors_error' ||
(ctx.error.id === 'request_error' && ctx.error.status >= 500)
);
},
onRetry: (ctx) => {
console.log(
`Retrying request... Attempt: ${ctx.retryAttempt}, Error: ${ctx.error.id}, Duration: ${ctx.errorDuration}ms`,
);
},
},
logger: customLogger,
},
);
result
.ifOk((product) => console.log('Product data:', product))
.ifErr((error) =>
console.error(
`Failed to get product after retries: ${error.id} - ${error.message}`,
),
);
}Custom Logging
For Node.js environments, use the built-in getNodeLogger utility for styled console output:
import { typedFetch } from '@ls-stack/typed-fetch';
import { getNodeLogger } from '@ls-stack/typed-fetch/nodeLogger';
// Use the built-in Node.js logger with styling and formatting
const nodeLogger = getNodeLogger({
indent: 2, // Indent logs by 2 spaces
hostAlias: 'MyAPI', // Show "MyAPI" instead of the full host URL
});
async function fetchDataWithNodeLogger() {
await typedFetch('/data', {
host: 'https://api.example.com',
method: 'GET',
logger: nodeLogger,
});
}For custom logging implementations:
import { typedFetch, TypedFetchLogger } from '@ls-stack/typed-fetch';
const customLogger: TypedFetchLogger = (logId, url, method, startTimestamp) => {
return {
success: () => {
const duration = Date.now() - startTimestamp;
console.log(
`[${logId}] ${method} ${url.pathname} - Success (${duration}ms)`,
);
},
error: (status) => {
const duration = Date.now() - startTimestamp;
console.log(
`[${logId}] ${method} ${url.pathname} - Error: ${status} (${duration}ms)`,
);
},
};
};
async function fetchDataWithCustomLog() {
await typedFetch('/data', {
host: 'https://api.example.com',
method: 'GET',
logger: customLogger,
});
}0.16.0
6 months ago
0.15.1
6 months ago
0.15.0
6 months ago
0.14.0
6 months ago
0.13.0
6 months ago
0.12.0
6 months ago
0.11.0
6 months ago
0.10.0
6 months ago
0.9.0
6 months ago
0.8.0
6 months ago
0.7.0
6 months ago
0.6.0
6 months ago
0.5.0
6 months ago
0.4.0
6 months ago
0.3.0
6 months ago
0.2.1
7 months ago
0.2.0
7 months ago
0.1.1
7 months ago
0.1.0
7 months ago