77 *
88 * Provides {@linkcode createProblemDetailsResponse} to build a `Response` with
99 * an `application/problem+json` body, {@linkcode parseProblemDetails} to parse
10- * from a `Response` or plain object, and {@linkcode isProblemDetailsResponse}
11- * to detect problem-details responses by content type.
10+ * a plain object, {@linkcode parseProblemDetailsResponse} to parse a `Response`
11+ * body, and {@linkcode isProblemDetailsResponse} to detect problem-details
12+ * responses by content type.
1213 *
1314 * @example Basic 404 response
1415 * ```ts
2021 * });
2122 * ```
2223 *
23- * @example Parse from a Response
24+ * @example Parse a Response
2425 * ```ts ignore
2526 * import {
2627 * isProblemDetailsResponse,
27- * parseProblemDetails ,
28+ * parseProblemDetailsResponse ,
2829 * } from "@std/http/unstable-problem-details";
2930 *
3031 * const response = await fetch("https://api.example.com/resource");
3132 * if (isProblemDetailsResponse(response)) {
32- * const problem = await parseProblemDetails (response);
33+ * const problem = await parseProblemDetailsResponse (response);
3334 * console.error(problem.detail);
3435 * }
3536 * ```
3940 *
4041 * @experimental **UNSTABLE**: New API, yet to be vetted.
4142 *
42- * @see {@link https://www.rfc-editor.org/rfc/rfc9457.html }
43+ * @see {@link https://www.rfc-editor.org/rfc/rfc9457.html | RFC 9457 }
4344 *
4445 * @module
4546 */
@@ -62,14 +63,16 @@ export type StandardProblemDetailsMember =
6263 | "instance" ;
6364
6465/**
65- * Constraint for Problem Details extension members. Permits any string-keyed
66- * properties except the five standard members defined by RFC 9457.
66+ * Constraint for Problem Details extension members.
6767 *
68- * Uses `Omit` rather than a mapped `never` constraint so that TypeScript can
69- * infer `T` from object literals at call sites without the standard keys
70- * being captured into `T` and then failing a `never` check. If `T`
71- * explicitly redeclares a standard key, the intersection with the base type
72- * collapses that key to `never`, making it unusable.
68+ * The constraint itself is intentionally loose (effectively
69+ * `Record<string, unknown>`) so that TypeScript can infer `T` from object
70+ * literals at call sites without standard keys being captured into `T` and
71+ * then failing a stricter `never` check. Safety is enforced one level up: the
72+ * intersection in {@linkcode ProblemDetails} explicitly types the five
73+ * standard members, so if `T` redeclares any of them with an incompatible
74+ * type the resulting field collapses to `never` and the value becomes
75+ * unconstructible.
7376 *
7477 * @experimental **UNSTABLE**: New API, yet to be vetted.
7578 */
@@ -86,13 +89,14 @@ export type ProblemDetailsExtensions = Omit<
8689 * top-level properties in both the TypeScript type and the serialized JSON
8790 * — matching the wire format exactly.
8891 *
89- * The generic constraint on `T` prevents extension types from shadowing the
90- * five standard members, which the RFC forbids.
92+ * If `T` declares a property whose name matches one of the five standard
93+ * members with an incompatible type, the intersection collapses that field
94+ * to `never`.
9195 *
9296 * @experimental **UNSTABLE**: New API, yet to be vetted.
9397 */
9498export type ProblemDetails <
95- T extends ProblemDetailsExtensions = Record < never , never > ,
99+ T extends ProblemDetailsExtensions = Record < string , never > ,
96100> = {
97101 /**
98102 * A URI reference identifying the problem type. Defaults to `"about:blank"`
@@ -117,6 +121,13 @@ export type ProblemDetails<
117121export interface ProblemDetailsResponseOptions {
118122 /** Additional headers to include in the response. */
119123 headers ?: HeadersInit ;
124+ /**
125+ * Status text for the response. When omitted, the `Response` constructor's
126+ * default (the empty string, per the Fetch spec) is used; we deliberately
127+ * do not synthesize one from the standard HTTP status text — the JSON
128+ * `title` field already carries that information.
129+ */
130+ statusText ?: string ;
120131}
121132
122133/**
@@ -131,6 +142,10 @@ export interface ProblemDetailsResponseOptions {
131142 * - Defaults `status` to `500` if not provided.
132143 * - Serializes directly to JSON — the flat intersection type already matches
133144 * the RFC wire format, so no flattening step is needed.
145+ * - Forwards `options.statusText` to the `Response` only when defined; when
146+ * omitted, the constructor's empty-string default is used and the standard
147+ * HTTP status text is intentionally not synthesized — the JSON `title`
148+ * field already carries that information.
134149 *
135150 * @experimental **UNSTABLE**: New API, yet to be vetted.
136151 *
@@ -142,6 +157,9 @@ export interface ProblemDetailsResponseOptions {
142157 * @returns A `Response` with status, `application/problem+json` content type,
143158 * and the serialized problem details as the body.
144159 *
160+ * @throws {RangeError } If `problemDetails.status` is not a finite integer, or
161+ * is any value the `Response` constructor rejects (i.e. outside 200–599).
162+ *
145163 * @example Basic 404 response
146164 * ```ts
147165 * import { createProblemDetailsResponse } from "@std/http/unstable-problem-details";
@@ -193,6 +211,14 @@ export function createProblemDetailsResponse<
193211
194212 if ( pd . status === undefined ) pd . status = 500 ;
195213
214+ if ( ! Number . isInteger ( pd . status ) ) {
215+ throw new RangeError (
216+ `Cannot create Problem Details response: status must be a finite integer, received ${
217+ typeof pd . status === "string" ? `"${ pd . status } "` : String ( pd . status )
218+ } `,
219+ ) ;
220+ }
221+
196222 if ( pd . type === "about:blank" && pd . title === undefined ) {
197223 const statusText = STATUS_TEXT [ pd . status as keyof typeof STATUS_TEXT ] ;
198224 if ( statusText !== undefined ) {
@@ -202,21 +228,26 @@ export function createProblemDetailsResponse<
202228
203229 const body = JSON . stringify ( pd ) ;
204230 const status = pd . status as number ;
231+ const statusText = options ?. statusText ;
205232
206233 if ( options ?. headers === undefined ) {
207234 return new Response ( body , {
208235 status,
236+ ...( statusText !== undefined ? { statusText } : { } ) ,
209237 headers : { "Content-Type" : PROBLEM_JSON_MEDIA_TYPE } ,
210238 } ) ;
211239 }
212240 const headers = new Headers ( options . headers ) ;
213241 headers . set ( "Content-Type" , PROBLEM_JSON_MEDIA_TYPE ) ;
214- return new Response ( body , { status, headers } ) ;
242+ return new Response ( body , {
243+ status,
244+ ...( statusText !== undefined ? { statusText } : { } ) ,
245+ headers,
246+ } ) ;
215247}
216248
217- /** Per RFC 9457 §3.1: ignore standard members whose value type does not match. */
218249function normalizeParsedProblemDetails (
219- raw : Record < string , unknown > ,
250+ raw : unknown ,
220251) : Record < string , unknown > {
221252 if ( typeof raw !== "object" || raw === null || Array . isArray ( raw ) ) {
222253 throw new TypeError (
@@ -227,10 +258,12 @@ function normalizeParsedProblemDetails(
227258 }
228259
229260 const result : Record < string , unknown > = { } ;
261+ const source = raw as Record < string , unknown > ;
230262
231- for ( const key in raw ) {
232- if ( ! Object . hasOwn ( raw , key ) ) continue ;
233- const value = raw [ key ] ;
263+ // RFC 9457 §3.1: ignore standard members whose value type does not match.
264+ for ( const key in source ) {
265+ if ( ! Object . hasOwn ( source , key ) ) continue ;
266+ const value = source [ key ] ;
234267 switch ( key ) {
235268 case "type" :
236269 if ( typeof value === "string" ) result . type = value ;
@@ -256,57 +289,26 @@ function normalizeParsedProblemDetails(
256289}
257290
258291/**
259- * Parses a `Response` body into a {@linkcode ProblemDetails}.
260- *
261- * Reads the response body as JSON and returns the standard members plus any
262- * extension members as a flat object. Standard members with invalid types are
263- * ignored per RFC 9457 §3.1. Does not throw on missing fields — the RFC makes
264- * all members optional. Extension member types provided via `T` are asserted at
265- * the type level only — values are not validated at runtime.
266- *
267- * Note: this consumes the response body. The `Response` cannot be re-read
268- * after this call.
269- *
270- * @experimental **UNSTABLE**: New API, yet to be vetted.
271- *
272- * @typeParam T The type of extension members expected in the parsed result.
273- *
274- * @param input The `Response` whose JSON body will be parsed.
275- *
276- * @returns A promise that resolves to the parsed problem details.
277- *
278- * @example Parse from a Response
279- * ```ts ignore
280- * import { parseProblemDetails } from "@std/http/unstable-problem-details";
281- *
282- * const response = await fetch("https://api.example.com/resource");
283- * if (isProblemDetailsResponse(response)) {
284- * const problem = await parseProblemDetails(response);
285- * console.log(problem.status, problem.detail);
286- * }
287- * ```
288- */
289- export function parseProblemDetails <
290- T extends ProblemDetailsExtensions = Record < never , never > ,
291- > ( input : Response ) : Promise < ProblemDetails < T > > ;
292-
293- /**
294- * Parses a plain JSON object into a {@linkcode ProblemDetails}.
292+ * Parses a plain JSON value into a {@linkcode ProblemDetails}.
295293 *
296294 * Returns the standard members plus any extension members as a flat object.
297- * Standard members with invalid types are ignored per RFC 9457 §3.1. Does not
298- * throw on missing fields — the RFC makes all members optional. Extension
299- * member types provided via `T` are asserted at the type level only — values
300- * are not validated at runtime.
295+ * Standard members with invalid types are silently dropped per RFC 9457 §3.1.
296+ * Does not throw on missing fields — the RFC makes all members optional.
297+ * Extension member types provided via `T` are asserted at the type level only;
298+ * values are not validated at runtime.
301299 *
302300 * @experimental **UNSTABLE**: New API, yet to be vetted.
303301 *
304302 * @typeParam T The type of extension members expected in the parsed result.
305303 *
306- * @param input A plain JSON object to parse as problem details.
304+ * @param input A JSON value to parse as problem details.
307305 *
308306 * @returns The parsed problem details.
309307 *
308+ * @throws {TypeError } If `input` is not a non-null, non-array object, or if
309+ * `input` is a `Response` (use {@linkcode parseProblemDetailsResponse}
310+ * instead).
311+ *
310312 * @example Parse from a plain object
311313 * ```ts
312314 * import { parseProblemDetails } from "@std/http/unstable-problem-details";
@@ -323,28 +325,71 @@ export function parseProblemDetails<
323325 * ```
324326 */
325327export function parseProblemDetails <
326- T extends ProblemDetailsExtensions = Record < never , never > ,
327- > ( input : Record < string , unknown > ) : ProblemDetails < T > ;
328-
329- export function parseProblemDetails <
330- T extends ProblemDetailsExtensions = Record < never , never > ,
331- > (
332- input : Response | Record < string , unknown > ,
333- ) : Promise < ProblemDetails < T > > | ProblemDetails < T > {
328+ T extends ProblemDetailsExtensions = Record < string , never > ,
329+ > ( input : unknown ) : ProblemDetails < T > {
330+ // A `Response` is a non-null, non-array object with no enumerable own keys,
331+ // so it would silently parse to an empty result. Reject it explicitly so
332+ // callers who forgot to migrate to `parseProblemDetailsResponse` fail loudly.
334333 if ( input instanceof Response ) {
335- return input . json ( ) . then ( ( raw : Record < string , unknown > ) =>
336- normalizeParsedProblemDetails ( raw ) as ProblemDetails < T >
334+ throw new TypeError (
335+ "Cannot parse Problem Details: input is a Response, use parseProblemDetailsResponse instead" ,
337336 ) ;
338337 }
339338 return normalizeParsedProblemDetails ( input ) as ProblemDetails < T > ;
340339}
341340
341+ /**
342+ * Parses a `Response` body into a {@linkcode ProblemDetails}.
343+ *
344+ * Reads the response body as JSON and delegates to
345+ * {@linkcode parseProblemDetails}. Standard members with invalid types are
346+ * silently dropped per RFC 9457 §3.1. Does not throw on missing fields — the
347+ * RFC makes all members optional. Extension member types provided via `T` are
348+ * asserted at the type level only; values are not validated at runtime.
349+ *
350+ * Note: this consumes the response body. The `Response` cannot be re-read
351+ * after this call.
352+ *
353+ * @experimental **UNSTABLE**: New API, yet to be vetted.
354+ *
355+ * @typeParam T The type of extension members expected in the parsed result.
356+ *
357+ * @param input The `Response` whose JSON body will be parsed.
358+ *
359+ * @returns A promise that resolves to the parsed problem details.
360+ *
361+ * @throws {TypeError } If the response body parses to a value that is not a
362+ * non-null, non-array object.
363+ * @throws {SyntaxError } If the response body is not valid JSON.
364+ *
365+ * @example Parse from a Response
366+ * ```ts
367+ * import { parseProblemDetailsResponse } from "@std/http/unstable-problem-details";
368+ * import { assertEquals } from "@std/assert";
369+ *
370+ * const response = new Response(
371+ * JSON.stringify({ type: "about:blank", status: 404, title: "Not Found" }),
372+ * { headers: { "Content-Type": "application/problem+json" } },
373+ * );
374+ * const problem = await parseProblemDetailsResponse(response);
375+ * assertEquals(problem.status, 404);
376+ * assertEquals(problem.title, "Not Found");
377+ * ```
378+ */
379+ export async function parseProblemDetailsResponse <
380+ T extends ProblemDetailsExtensions = Record < string , never > ,
381+ > ( input : Response ) : Promise < ProblemDetails < T > > {
382+ const raw = await input . json ( ) ;
383+ return normalizeParsedProblemDetails ( raw ) as ProblemDetails < T > ;
384+ }
385+
342386/**
343387 * Type guard that checks whether a `Response` has an
344388 * `application/problem+json` content type.
345389 *
346390 * The media type is compared without parameters (e.g. `charset=utf-8` is
347- * ignored).
391+ * ignored). Only `application/problem+json` is recognized; any other media
392+ * type returns `false`.
348393 *
349394 * @experimental **UNSTABLE**: New API, yet to be vetted.
350395 *
@@ -357,12 +402,12 @@ export function parseProblemDetails<
357402 * ```ts ignore
358403 * import {
359404 * isProblemDetailsResponse,
360- * parseProblemDetails ,
405+ * parseProblemDetailsResponse ,
361406 * } from "@std/http/unstable-problem-details";
362407 *
363408 * const response = await fetch("https://api.example.com/resource");
364409 * if (isProblemDetailsResponse(response)) {
365- * const problem = await parseProblemDetails (response);
410+ * const problem = await parseProblemDetailsResponse (response);
366411 * console.error(problem.detail);
367412 * }
368413 * ```
0 commit comments