@ololoepepe/controllers 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-08-29
11
+
12
+ ### Changed
13
+
14
+ - `Json` accepts readonly arrays: the array member is now `readonly Json[]`. A
15
+ `ReadonlyArray` field, or anything produced by `as const`, could not be a response
16
+ body, because a readonly array is not assignable to a mutable one. Mutable arrays are
17
+ unaffected — they are assignable to readonly ones, so every body that compiled before
18
+ still compiles.
19
+
20
+ Breaking only for code that passes a value typed `Json` on to a JSON type declaring
21
+ its arrays mutable — another library's `JsonValue`, or a copy of this one. The same
22
+ goes for `Extract<Json, unknown[]>`, which now resolves to `never` and fails at the
23
+ use site rather than where it is declared.
24
+
25
+ ## [0.4.0] - 2026-08-28
26
+
27
+ ### Changed
28
+
29
+ - `JsonResponse` takes the body as a type parameter constrained to `Json` instead of
30
+ `unknown`, and `jsonController` gained a matching third parameter, inferred from the
31
+ callback. The body of a JSON response was typed `unknown`, so `json(undefined)`
32
+ type-checked even though `undefined` is not a JSON value. Also rejected are `bigint`
33
+ (`JSON.stringify` throws on it), `symbol` and functions, nested fields included.
34
+
35
+ A callback that returns nothing still sends the empty object, which is what
36
+ `EmptyJsonBody` names, so a response has to admit it alongside its own body type —
37
+ `JsonResponse<EmptyJsonBody | Tenant>`. `null` is unaffected: it is a JSON value and
38
+ goes out as it is.
39
+
40
+ Response bodies now have to be declared with `type` rather than `interface`:
41
+ TypeScript assigns no `interface` to a type with an index signature, which `Json`
42
+ rests on.
43
+
44
+ Breaking for code that names `JsonResponse` explicitly; the type argument is now
45
+ required. Code that only passes callbacks is unaffected.
46
+
47
+ ### Added
48
+
49
+ - Exported types `Json` and `EmptyJsonBody`.
50
+
10
51
  ## [0.3.0] - 2026-08-28
11
52
 
12
53
  ### Changed
package/README.md CHANGED
@@ -149,17 +149,62 @@ The request and the response are type parameters, defaulting to Express's
149
149
  your own shapes — the controllers only require what they actually use:
150
150
 
151
151
  ```ts
152
- import {jsonController, type ControllerRequest, type JsonResponse} from '@ololoepepe/controllers';
152
+ import {
153
+ jsonController,
154
+ type ControllerRequest,
155
+ type EmptyJsonBody,
156
+ type JsonResponse
157
+ } from '@ololoepepe/controllers';
158
+
159
+ type Tenant = {
160
+ id: string;
161
+ name: string;
162
+ };
153
163
 
154
164
  interface TenantRequest extends ControllerRequest {
155
165
  tenantId: string;
156
166
  }
157
167
 
158
- const controller = jsonController<TenantRequest, JsonResponse>(request => loadTenant(request.tenantId));
168
+ const controller = jsonController<TenantRequest, JsonResponse<EmptyJsonBody | Tenant>, Tenant>(
169
+ request => loadTenant(request.tenantId)
170
+ );
159
171
  ```
160
172
 
173
+ `jsonController` has a third parameter for the body, inferred from the callback,
174
+ so you only name it when you name the response as well. It is constrained to
175
+ `Json` — what JSON can actually hold:
176
+
177
+ ```ts
178
+ export type Json = boolean | null | number | readonly Json[] | string | {[key: string]: Json};
179
+ ```
180
+
181
+ That rules out `undefined`, `bigint` (`JSON.stringify` throws on it), `symbol`
182
+ and functions, and it checks nested fields too.
183
+
184
+ Arrays are `readonly`, so a `ReadonlyArray` field or an `as const` value can be a
185
+ body. A mutable array still passes, being assignable to a readonly one.
186
+
187
+ **Describe response bodies with `type`, not `interface`.** TypeScript does not
188
+ assign an `interface` to a type with an index signature, so an interface will not
189
+ satisfy `Json` however JSON-shaped it is — including one nested inside a `type`:
190
+
191
+ ```ts
192
+ interface Tenant {id: string} // will not compile as a body
193
+ type Tenant = {id: string}; // will
194
+ ```
195
+
196
+ `Date` does not satisfy `Json` either. Say `string`, which is what a date is by
197
+ the time it reaches the client anyway — `JSON.stringify` calls `toJSON` on it.
198
+ Optional properties are fine: `type Tenant = {id: string; note?: string}` passes.
199
+
200
+ The callback may still return `undefined`; that is the documented "no body" case,
201
+ and what goes out instead is the empty object `EmptyJsonBody`. This is why the
202
+ response above has to accept `EmptyJsonBody | Tenant` rather than just `Tenant`.
203
+ `null` is left alone, being a JSON value like any other.
204
+
161
205
  Exported types: `Controller`, `ControllerCallback`, `ControllerRequest`,
162
- `JsonResponse`, `NextFunction`, `NoContentResponse`, `StringResponse`.
206
+ `EmptyJsonBody`, `Json`, `JsonResponse`, `NextFunction`, `NoContentResponse`,
207
+ `StringResponse`.
163
208
 
164
209
  ## Development
165
210
 
@@ -1,5 +1,5 @@
1
1
  import type { Request, Response } from 'express';
2
- import type { Controller, ControllerCallback, ControllerRequest, JsonResponse } from '#src/types.ts';
2
+ import type { Controller, ControllerCallback, ControllerRequest, EmptyJsonBody, Json, JsonResponse } from '#src/types.ts';
3
3
  /**
4
4
  * Создает контроллер, который вызывает `callback` на каждый запрос и отправляет его результат
5
5
  * в виде JSON.
@@ -14,4 +14,4 @@ import type { Controller, ControllerCallback, ControllerRequest, JsonResponse }
14
14
  * @param callback - функция, вызываемая на входящий запрос
15
15
  * @param statusCode - код ответа, если нужен не тот, что выбирается по методу запроса
16
16
  */
17
- export default function jsonController<TRequest extends ControllerRequest = Request, TResponse extends JsonResponse = Response>(callback: ControllerCallback<TRequest, TResponse>, statusCode?: number): Controller<TRequest, TResponse>;
17
+ export default function jsonController<TRequest extends ControllerRequest = Request, TResponse extends JsonResponse<EmptyJsonBody | TBody> = Response, TBody extends Json = EmptyJsonBody>(callback: ControllerCallback<TRequest, TResponse, TBody | undefined>, statusCode?: number): Controller<TRequest, TResponse>;
@@ -17,7 +17,10 @@ export default function jsonController(callback, statusCode) {
17
17
  return async (request, response, next) => {
18
18
  try {
19
19
  const result = await callback(request, response);
20
- // Пустым объектом подменяется только `undefined`: `null` — осмысленное тело ответа
20
+ // Подменяется только `undefined`: значения `undefined` в JSON нет, а `null` — осмысленное
21
+ // тело ответа. Правило считает эту тернарку равнозначной `??`, потому что не видит `null`
22
+ // внутри свободного параметра `TBody`, но `??` подменил бы и его.
23
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `??` подменил бы и `null`
21
24
  const body = (result === undefined) ? {} : result;
22
25
  response.status(statusCode ?? selectStatusCode(request.method)).json(body);
23
26
  }
@@ -1,2 +1,2 @@
1
1
  export { jsonController, noContentController, stringController } from '#src/controllers/index.ts';
2
- export type { Controller, ControllerCallback, ControllerRequest, JsonResponse, NextFunction, NoContentResponse, StringResponse } from '#src/types.ts';
2
+ export type { Controller, ControllerCallback, ControllerRequest, EmptyJsonBody, Json, JsonResponse, NextFunction, NoContentResponse, StringResponse } from '#src/types.ts';
@@ -11,10 +11,31 @@ export type NextFunction = (error?: unknown) => void;
11
11
  export interface ControllerRequest {
12
12
  method: string;
13
13
  }
14
+ /**
15
+ * Значение, которое можно отдать в JSON.
16
+ *
17
+ * Того, чего в JSON нет, здесь и не перечислено: `undefined`, `bigint` (на нем `JSON.stringify`
18
+ * падает), `symbol` и функция телом ответа стать не смогут.
19
+ *
20
+ * У объектов проверяются и поля, вглубь. Цена этого в том, что TypeScript не присваивает
21
+ * `interface` типу с индексной сигнатурой, поэтому тело ответа описывается через `type`, а не
22
+ * через `interface`.
23
+ *
24
+ * Массив взят `readonly`, чтобы телом ответа могли стать `ReadonlyArray` и результат `as const`.
25
+ * Изменяемый массив подходит и так: он присваивается в `readonly`, обратное неверно.
26
+ */
27
+ export type Json = boolean | null | number | readonly Json[] | string | {
28
+ [key: string]: Json;
29
+ };
30
+ /**
31
+ * Тело, которое контроллер отправляет вместо `undefined`: значения `undefined` в JSON нет,
32
+ * поэтому вместо него уходит пустой объект.
33
+ */
34
+ export type EmptyJsonBody = Record<string, never>;
14
35
  /** Ответ, умеющий отдать тело в виде JSON. */
15
- export interface JsonResponse {
36
+ export interface JsonResponse<TBody extends Json> {
16
37
  status: (statusCode: number) => {
17
- json: (body: unknown) => unknown;
38
+ json: (body: TBody) => unknown;
18
39
  };
19
40
  }
20
41
  /** Ответ, умеющий отдать тело в виде строки. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ololoepepe/controllers",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Common Express controllers library",
5
5
  "type": "module",
6
6
  "imports": {