0.16.0 • Published 11 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
11 months ago
0.15.1
11 months ago
0.15.0
11 months ago
0.14.0
11 months ago
0.13.0
11 months ago
0.12.0
11 months ago
0.11.0
11 months ago
0.10.0
11 months ago
0.9.0
11 months ago
0.8.0
11 months ago
0.7.0
11 months ago
0.6.0
11 months ago
0.5.0
12 months ago
0.4.0
12 months ago
0.3.0
12 months ago
0.2.1
1 year ago
0.2.0
1 year ago
0.1.1
1 year ago
0.1.0
1 year ago