From b1c50597088c2e4e1d3666f53f3f9de171cbd506 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Thu, 2 Mar 2023 08:56:13 +0900 Subject: [PATCH] feat(`jsonT`): check JSON type (#939) * feat(`jsonT`): check JSON type * denoify * use `JSONValue` * create `TrueAndFalseToBoolean` and add a comment --- deno_dist/context.ts | 10 ++++++---- deno_dist/utils/types.ts | 18 ++++++++++++++++++ src/client/client.test.ts | 11 +++++++++++ src/context.ts | 10 ++++++---- src/utils/types.ts | 18 ++++++++++++++++++ 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/deno_dist/context.ts b/deno_dist/context.ts index cc84d7d4..876accf2 100644 --- a/deno_dist/context.ts +++ b/deno_dist/context.ts @@ -4,6 +4,7 @@ import type { Env, NotFoundHandler, Input } from './types.ts' import type { CookieOptions } from './utils/cookie.ts' import { serialize } from './utils/cookie.ts' import type { StatusCode } from './utils/http-status.ts' +import type { PrettyJSON, JSONValue } from './utils/types.ts' type Runtime = 'node' | 'deno' | 'bun' | 'workerd' | 'fastly' | 'edge-light' | 'lagon' | 'other' type HeaderRecord = Record @@ -237,14 +238,15 @@ export class Context< return this.newResponse(body, status, headers) } - jsonT = ( - object: T, + jsonT = ( + object: T extends JSONValue ? T : JSONValue, status: StatusCode = this._status, headers?: HeaderRecord - ): TypedResponse => { + ): TypedResponse) : never> => { return { response: this.json(object, status, headers), - data: object, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: object as any, format: 'json', } } diff --git a/deno_dist/utils/types.ts b/deno_dist/utils/types.ts index 20057566..38b8e25f 100644 --- a/deno_dist/utils/types.ts +++ b/deno_dist/utils/types.ts @@ -10,3 +10,21 @@ export type UnionToIntersection = (U extends any ? (k: U) => void : never) ex ) => void ? I : never + +export type JSONPrimitive = string | boolean | number | null | undefined +export type JSONArray = (JSONPrimitive | JSONObject | JSONArray)[] +export type JSONObject = { [key: string]: JSONPrimitive | JSONArray | JSONObject } +export type JSONValue = JSONObject | JSONArray | JSONPrimitive + +// `boolean` will be `true` or `false` because it's an alias of them. +// See: https://github.com/microsoft/TypeScript/issues/22596 +// This type converts `true | false` to `boolean` that we expect. +type TrueAndFalseToBoolean = T extends true ? boolean : T extends false ? boolean : T + +export type PrettyJSON = T extends JSONPrimitive + ? TrueAndFalseToBoolean + : T extends JSONArray + ? PrettyJSON + : T extends JSONObject + ? { [K in keyof T]: PrettyJSON } + : never diff --git a/src/client/client.test.ts b/src/client/client.test.ts index 13db0017..cd39ff30 100644 --- a/src/client/client.test.ts +++ b/src/client/client.test.ts @@ -332,6 +332,17 @@ describe('Merge path with `app.route()`', () => { expect(data.ok).toBe(true) }) + it('Should not allow the incorrect JSON type', async () => { + const app = new Hono() + // @ts-ignore + const route = app.get('/api/foo', (c) => c.jsonT({ datetime: new Date() })) + type AppType = typeof route + const client = hc('http://localhost') + const res = await client.api.foo.$get() + const data = await res.json() + type verify = Expect> + }) + describe('Multiple endpoints', () => { const api = new Hono() .get('/foo', (c) => c.jsonT({ foo: '' })) diff --git a/src/context.ts b/src/context.ts index 922e94fe..a928f04c 100644 --- a/src/context.ts +++ b/src/context.ts @@ -4,6 +4,7 @@ import type { Env, NotFoundHandler, Input } from './types' import type { CookieOptions } from './utils/cookie' import { serialize } from './utils/cookie' import type { StatusCode } from './utils/http-status' +import type { PrettyJSON, JSONValue } from './utils/types' type Runtime = 'node' | 'deno' | 'bun' | 'workerd' | 'fastly' | 'edge-light' | 'lagon' | 'other' type HeaderRecord = Record @@ -237,14 +238,15 @@ export class Context< return this.newResponse(body, status, headers) } - jsonT = ( - object: T, + jsonT = ( + object: T extends JSONValue ? T : JSONValue, status: StatusCode = this._status, headers?: HeaderRecord - ): TypedResponse => { + ): TypedResponse) : never> => { return { response: this.json(object, status, headers), - data: object, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: object as any, format: 'json', } } diff --git a/src/utils/types.ts b/src/utils/types.ts index 20057566..38b8e25f 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -10,3 +10,21 @@ export type UnionToIntersection = (U extends any ? (k: U) => void : never) ex ) => void ? I : never + +export type JSONPrimitive = string | boolean | number | null | undefined +export type JSONArray = (JSONPrimitive | JSONObject | JSONArray)[] +export type JSONObject = { [key: string]: JSONPrimitive | JSONArray | JSONObject } +export type JSONValue = JSONObject | JSONArray | JSONPrimitive + +// `boolean` will be `true` or `false` because it's an alias of them. +// See: https://github.com/microsoft/TypeScript/issues/22596 +// This type converts `true | false` to `boolean` that we expect. +type TrueAndFalseToBoolean = T extends true ? boolean : T extends false ? boolean : T + +export type PrettyJSON = T extends JSONPrimitive + ? TrueAndFalseToBoolean + : T extends JSONArray + ? PrettyJSON + : T extends JSONObject + ? { [K in keyof T]: PrettyJSON } + : never