From 9ad99eaf5d2c68e4c2d6ebf15ca8094ea42d41d5 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 12 May 2026 14:49:24 -0400 Subject: [PATCH] fix(types): widen Query/Params/Headers to unknown 21.4.5 narrowing blocked global ReqRefDefaults augmentation. Per @kanongil on #4562: https://github.com/hapijs/hapi/pull/4562#discussion_r3201524421 --- lib/types/request.d.ts | 6 +- test/types/index.ts | 124 ++++++++++++++++++++++++++++++----------- typescript.md | 12 ++-- 3 files changed, 102 insertions(+), 40 deletions(-) diff --git a/lib/types/request.d.ts b/lib/types/request.d.ts index 23452c54e..6f91c6d68 100644 --- a/lib/types/request.d.ts +++ b/lib/types/request.d.ts @@ -252,7 +252,7 @@ export interface RequestLog { } export interface RequestQuery { - [key: string]: string | string[] | undefined; + [key: string]: unknown; } /** @@ -271,9 +271,9 @@ export interface InternalRequestDefaults { Payload: stream.Readable | Buffer | string | object; Query: RequestQuery; - Params: Record; + Params: Record; Pres: Record; - Headers: Record; + Headers: Record; RequestApp: RequestApplicationState; AuthUser: UserCredentials; diff --git a/test/types/index.ts b/test/types/index.ts index ec1212fcc..69fbfeeb2 100644 --- a/test/types/index.ts +++ b/test/types/index.ts @@ -5,6 +5,7 @@ import * as CatboxMemory from '@hapi/catbox-memory'; import { Plugin, ReqRef, + ReqRefDefaults, Request, RequestRoute, ResponseToolkit, @@ -106,7 +107,7 @@ const route: ServerRoute = { request.app.word = 'x'; - check.type>(request.params); + check.type>(request.params); check.type(request.server.app.multi!); check.type(request.route.settings.app!.prefix); @@ -383,73 +384,61 @@ const issuePreAssign: ServerRoute<{ Params: { id: string } }> = { } }; -// ----------------------------------------------------------------------------- -// ISSUE 2: Params defaults to Record — allows unsafe access -// -// URL path params are ALWAYS strings at runtime (before Joi validation), but -// the default type Record means TypeScript allows anything. -// These assignments should all be errors but none are. -// ----------------------------------------------------------------------------- +// ISSUE 2: Params default is `Record` — Joi-driven +// conversion can change the runtime type, so callsites must narrow. const issueParamsAny: ServerRoute = { method: 'GET', path: '/items/{id}', handler: (request, h) => { - // FIXED: Params now correctly typed as Record - // @ts-expect-error - params are strings, not numbers - const id: number = request.params.id; - // @ts-expect-error - params are strings, not boolean[] + // @ts-expect-error - unknown is not number + const idAsNum: number = request.params.id; + // @ts-expect-error - unknown is not string (compiled pre-fix when Params was Record) + const idAsStr: string = request.params.id; + // @ts-expect-error - unknown is not boolean[] const wat: boolean[] = request.params.id; - // FIXED: params is no longer `any` const paramsIsAny: IsAny = false; return 'ok'; } }; -// ----------------------------------------------------------------------------- -// ISSUE 3: Headers defaults to Record -// -// Node's http.IncomingHttpHeaders types headers as string | string[] | undefined. -// The Record default loses this. -// ----------------------------------------------------------------------------- +// ISSUE 3: Headers default is `Record` — header validation +// can transform values (e.g. `x-date` → Date). const issueHeadersAny: ServerRoute = { method: 'GET', path: '/headers', handler: (request, h) => { - // FIXED: Headers now correctly typed as Record - // @ts-expect-error - headers are string | string[] | undefined, not number + // @ts-expect-error - unknown is not number const auth: number = request.headers.authorization; + // @ts-expect-error - unknown is not string (issue #4563 — was assignable in 21.4.4) + const xCustom: string = request.headers['x-custom-header']; - // FIXED: headers is no longer `any` const headersIsAny: IsAny = false; return 'ok'; } }; -// ----------------------------------------------------------------------------- -// ISSUE 4: Default RequestQuery has [key: string]: any index signature -// -// Without a Query override, any access on request.query is `any`. -// ----------------------------------------------------------------------------- +// ISSUE 4: Query default is `[key: string]: unknown` — the configured parser +// (e.g. `qs` yields objects) and validation determine the runtime shape. const issueQueryAny: ServerRoute = { method: 'GET', path: '/search', handler: (request, h) => { - // FIXED: Query now correctly typed as Record - // @ts-expect-error - query values are string | string[] | undefined, not number + // @ts-expect-error - unknown is not number const page: number = request.query.page; - // @ts-expect-error - query values are string | string[] | undefined, not boolean[] + // @ts-expect-error - unknown is not string + const q: string = request.query.q; + // @ts-expect-error - unknown is not boolean[] const wat: boolean[] = request.query.anything; - // FIXED: query is no longer `any` const queryIsAny: IsAny = false; return 'ok'; @@ -515,3 +504,76 @@ const issueStateAny: ServerRoute = { return 'ok'; } }; + +// Headers/Params can be widened via global augmentation. + +type Extends = A extends B ? true : false; + +interface ParsedHeaders { + [key: string]: string | string[] | Date | number | undefined; +} +const _widensHeaders: Extends = true; + +interface ConvertedParams { [key: string]: string | number; } +const _widensParams: Extends = true; + +// Global Query augmentation must compile under `unknown` defaults and +// propagate through every generic position that reads MergeRefs['Query']. + +declare module '../..' { + interface ReqRefDefaults { + Query: { + [key: string]: string | string[] | Record | undefined; + }; + } +} + +type AugmentedQuery = string | string[] | Record | undefined; + +// ServerRoute with no generic — augmented Query flows in via Request. +const augNoGeneric: ServerRoute = { + method: 'GET', + path: '/aug-no-generic', + handler: (request, h) => { + + check.type(request.query.anything); + return 'ok'; + } +}; +server.route(augNoGeneric); + +// ServerRoute with partial Refs override — Query falls through MergeRefs as augmented. +const augPartialRefs: ServerRoute<{ Params: { id: string } }> = { + method: 'GET', + path: '/aug-partial/{id}', + handler: (request, h) => { + + check.type(request.query.foo); + check.type(request.params.id); + return 'ok'; + } +}; +server.route(augPartialRefs); + +// Lifecycle.Method (no generic) — same augmented Query. +const augLifecycle: Lifecycle.Method = (request, h) => { + + check.type(request.query.x); + return 'ok'; +}; +augLifecycle.length; // keep referenced + +// Per-route Query override still replaces the augmented default outright. +const qsParsedRoute: ServerRoute<{ Query: { filter: Record; q?: string } }> = { + method: 'GET', + path: '/qs', + handler: (request, h) => { + + check.type>(request.query.filter); + check.type(request.query.q); + + return 'ok'; + } +}; + +server.route(qsParsedRoute); diff --git a/typescript.md b/typescript.md index 5ac071254..d1944022c 100644 --- a/typescript.md +++ b/typescript.md @@ -49,10 +49,10 @@ Defines every customizable key and its default type: | Key | Default Type | Controls | | ---------------------- | ------------------------------------------------- | ---------------------------------------------- | | `Payload` | `stream.Readable \| Buffer \| string \| object` | `request.payload` | -| `Query` | `Record` | `request.query` | -| `Params` | `Record` | `request.params` | +| `Query` | `RequestQuery` (`Record`) | `request.query` | +| `Params` | `Record` | `request.params` | | `Pres` | `Record` | `request.pre` | -| `Headers` | `Record` | `request.headers` | +| `Headers` | `Record` | `request.headers` | | `RequestApp` | `RequestApplicationState` | `request.app` | | `AuthUser` | `UserCredentials` | `request.auth.credentials.user` | | `AuthApp` | `AppCredentials` | `request.auth.credentials.app` | @@ -117,7 +117,7 @@ const route: ServerRoute = { ### Params -Default: `Record`. URL path parameters are always strings at runtime (before validation), so the default type reflects this. +Default: `Record`. Raw path params are strings; Joi conversion can change that at runtime, so the default forces narrowing. ```typescript // Override with specific param names @@ -136,7 +136,7 @@ const route: ServerRoute<{ Params: { userId: string; postId: string } }> = { ### Query -Default: `Record`. Query params may be strings, arrays (repeated keys), or absent. +Default: `RequestQuery`, an augmentable interface with `[key: string]: unknown`. The shape depends on the configured parser (default produces `string | string[] | undefined`; `qs` yields nested objects) and on validation. Narrow per-route, or augment `RequestQuery` globally. ```typescript interface SearchQuery { @@ -185,7 +185,7 @@ const route: ServerRoute<{ Payload: CreateUserPayload }> = { ### Headers -Default: `Record`. Matches Node's `http.IncomingHttpHeaders` behavior. Override only if you need to narrow specific header names. +Default: `Record`. Raw headers match Node's `http.IncomingHttpHeaders` (`string | string[] | undefined`); validation can produce anything, so the default forces narrowing. ### RequestApp