From 77b27136752470d069b835420843f1b223ea627d Mon Sep 17 00:00:00 2001 From: Akim Mamedov Date: Mon, 1 Dec 2025 18:46:20 +0700 Subject: [PATCH 1/5] feat: add MaybeNull validator --- src/type-system/index.ts | 12 ++++++- test/type-system/maybe-null.test.ts | 53 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 test/type-system/maybe-null.test.ts diff --git a/src/type-system/index.ts b/src/type-system/index.ts index a5a3a082..b93e6f4e 100644 --- a/src/type-system/index.ts +++ b/src/type-system/index.ts @@ -1,4 +1,4 @@ -import { Type, Kind } from '@sinclair/typebox' +import { Type, Kind, Static } from '@sinclair/typebox' import type { ArrayOptions, DateOptions, @@ -492,12 +492,21 @@ export const ElysiaType = { }) .Encode((value) => value) as unknown as TUnsafe, + /** + * @deprecated Use MaybeNull instead which is OpenAPI 3.0 compliant. Will be removed in the next major release + */ Nullable: (schema: T, options?: SchemaOptions) => t.Union([schema, t.Null()], { ...options, nullable: true }), + MaybeNull: (schema: T): TUnsafe | null> => + Type.Unsafe({ + ...schema, + nullable: true, + }), + /** * Allow Optional, Nullable and Undefined */ @@ -660,6 +669,7 @@ t.Files = (arg) => { } t.Nullable = ElysiaType.Nullable +t.MaybeNull = ElysiaType.MaybeNull t.MaybeEmpty = ElysiaType.MaybeEmpty t.Cookie = ElysiaType.Cookie t.Date = ElysiaType.Date diff --git a/test/type-system/maybe-null.test.ts b/test/type-system/maybe-null.test.ts new file mode 100644 index 00000000..55980d4f --- /dev/null +++ b/test/type-system/maybe-null.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'bun:test' +import Elysia, { t } from '../../src' +import { post } from '../utils' + +describe('TypeSystem - MaybeNull', () => { + it('OpenAPI compliant', () => { + const schema = t.MaybeNull(t.String()); + + expect(schema).toMatchObject({ + type: "string", + nullable: true + }); + + const objSchema = t.Object({ + name: t.MaybeNull(t.String()) + }); + + expect(objSchema).toMatchObject({ + type: "object", + properties: { + name: { + type: "string", + nullable: true + } + }, + }); + }); + + it('Validate', async () => { + const app = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.Nullable(t.String()) + }) + }) + + const res1 = await app.handle( + post('/', { + name: '123' + }) + ) + expect(res1.status).toBe(200) + expect(await res1.json()).toEqual({ name: '123' }) + + const res2 = await app.handle(post('/', { + name: null + })) + expect(res2.status).toBe(200) + expect(await res2.json()).toEqual({ name: null }) + + const res3 = await app.handle(post('/', {})) + expect(res3.status).toBe(422) + }); +}) \ No newline at end of file From 6387023f2d3e8376c16bf6f49d3d22800fab27f5 Mon Sep 17 00:00:00 2001 From: Akim Mamedov Date: Mon, 1 Dec 2025 23:48:18 +0700 Subject: [PATCH 2/5] Add runtime validation and more tests --- src/type-system/index.ts | 70 +++++++--- src/type-system/types.ts | 7 + test/type-system/maybe-null.test.ts | 193 +++++++++++++++++++++++++--- 3 files changed, 239 insertions(+), 31 deletions(-) diff --git a/src/type-system/index.ts b/src/type-system/index.ts index b93e6f4e..d4527281 100644 --- a/src/type-system/index.ts +++ b/src/type-system/index.ts @@ -1,14 +1,17 @@ -import { Type, Kind, Static } from '@sinclair/typebox' import type { ArrayOptions, DateOptions, IntegerOptions, + JavaScriptTypeBuilder, + NumberOptions, ObjectOptions, SchemaOptions, + StringOptions, TAnySchema, TArray, TBoolean, TDate, + TEnum, TEnumValue, TInteger, TNumber, @@ -16,13 +19,14 @@ import type { TProperties, TSchema, TString, - NumberOptions, - JavaScriptTypeBuilder, - StringOptions, TUnsafe, - Uint8ArrayOptions, - TEnum + Uint8ArrayOptions } from '@sinclair/typebox' +import { Kind, Type } from '@sinclair/typebox' +import { + DefaultErrorFunction, + SetErrorFunction +} from '@sinclair/typebox/errors' import { compile, @@ -32,22 +36,25 @@ import { validateFile } from './utils' import { + AssertNumericEnum, CookieValidatorOptions, - TFile, - TFiles, + ElysiaTransformDecodeBuilder, FileOptions, FilesOptions, NonEmptyArray, + TArrayBuffer, + TFile, + TFiles, TForm, + TMaybeNull, TUnionEnum, - ElysiaTransformDecodeBuilder, - TArrayBuffer, - AssertNumericEnum + WrappedKind } from './types' import { ELYSIA_FORM_DATA, form } from '../utils' import { ValidationError } from '../error' import { parseDateTimeEmptySpace } from './format' +import { Value } from '@sinclair/typebox/value' const t = Object.assign({}, Type) as unknown as Omit< JavaScriptTypeBuilder, @@ -73,6 +80,30 @@ createType( (schema, value) => value instanceof ArrayBuffer ) +createType( + 'MaybeNull', + ({ [Kind]: kind, [WrappedKind]: wrappedKind, ...schema }, value) => { + return ( + Value.Check( + { + [Kind]: wrappedKind, + ...schema + }, + value + ) || value === null + ) + } +) + +SetErrorFunction((error) => { + switch (error.schema[Kind]) { + case 'MaybeNull': + return `Expected either ${error.schema.type} or null` + default: + return DefaultErrorFunction(error) + } +}) + const internalFiles = createType( 'Files', (options, value) => { @@ -153,7 +184,10 @@ export const ElysiaType = { .Encode((value) => value) as any as TNumber }, - NumericEnum>(item: T, property?: SchemaOptions) { + NumericEnum>( + item: T, + property?: SchemaOptions + ) { const schema = Type.Enum(item, property) const compiler = compile(schema) @@ -501,11 +535,15 @@ export const ElysiaType = { nullable: true }), - MaybeNull: (schema: T): TUnsafe | null> => - Type.Unsafe({ + MaybeNull: ({ [Kind]: kind, ...schema }: T): TSchema => { + return { + [Kind]: 'MaybeNull', + [WrappedKind]: kind, ...schema, - nullable: true, - }), + default: undefined, + nullable: true + } as unknown as TMaybeNull + }, /** * Allow Optional, Nullable and Undefined diff --git a/src/type-system/types.ts b/src/type-system/types.ts index 5b913c4a..03da4366 100644 --- a/src/type-system/types.ts +++ b/src/type-system/types.ts @@ -140,6 +140,13 @@ export interface TUnionEnum< enum: T } +export const WrappedKind = Symbol('WrappedKind'); + +export interface TMaybeNull extends TSchema { + [Kind]: 'MaybeNull', + [WrappedKind]: string, +} + export interface TArrayBuffer extends Uint8ArrayOptions {} export type TForm = TUnsafe< diff --git a/test/type-system/maybe-null.test.ts b/test/type-system/maybe-null.test.ts index 55980d4f..97f0aefd 100644 --- a/test/type-system/maybe-null.test.ts +++ b/test/type-system/maybe-null.test.ts @@ -4,32 +4,52 @@ import { post } from '../utils' describe('TypeSystem - MaybeNull', () => { it('OpenAPI compliant', () => { - const schema = t.MaybeNull(t.String()); + const schema = t.MaybeNull(t.String()) expect(schema).toMatchObject({ - type: "string", + type: 'string', nullable: true - }); + }) const objSchema = t.Object({ name: t.MaybeNull(t.String()) - }); + }) expect(objSchema).toMatchObject({ - type: "object", + type: 'object', properties: { name: { - type: "string", + type: 'string', nullable: true } + } + }) + + const schema1 = t.MaybeNull( + t.Object({ + a: t.String(), + b: t.Number() + }) + ) + + expect(schema1).toMatchObject({ + type: 'object', + properties: { + a: { + type: 'string' + }, + b: { + type: 'number' + } }, - }); - }); + nullable: true + }) + }) - it('Validate', async () => { + it('Validates primitive values', async () => { const app = new Elysia().post('/', ({ body }) => body, { body: t.Object({ - name: t.Nullable(t.String()) + name: t.MaybeNull(t.String()) }) }) @@ -41,13 +61,156 @@ describe('TypeSystem - MaybeNull', () => { expect(res1.status).toBe(200) expect(await res1.json()).toEqual({ name: '123' }) - const res2 = await app.handle(post('/', { - name: null - })) + const res2 = await app.handle( + post('/', { + name: null + }) + ) + expect(res2.status).toBe(200) expect(await res2.json()).toEqual({ name: null }) const res3 = await app.handle(post('/', {})) expect(res3.status).toBe(422) - }); -}) \ No newline at end of file + + const res4 = await app.handle( + post('/', { + name: 123 + }) + ) + + expect(res4.status).toBe(422) + + const res5 = await app.handle( + post('/', { + name: '' + }) + ) + expect(res5.status).toBe(200) + expect(await res5.json()).toEqual({ name: '' }) + + const app1 = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.MaybeNull(t.Number()) + }) + }) + + const res6 = await app1.handle( + post('/', { + name: '123' + }) + ) + + expect(res6.status).toBe(422) + + const res7 = await app1.handle( + post('/', { + name: 123 + }) + ) + + expect(res7.status).toBe(200) + expect(await res7.json()).toEqual({ name: 123 }) + }) + + it('Validates objects', async () => { + const appWithArray = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.Object({ + value: t.MaybeNull(t.Array(t.Number())) + }) + }) + }) + + const res1 = await appWithArray.handle( + post('/', { + name: { + value: [1, 2, 3] + } + }) + ) + + expect(res1.status).toBe(200) + expect(await res1.json()).toEqual({ name: { value: [1, 2, 3] } }) + + const res2 = await appWithArray.handle( + post('/', { + name: { + value: 'failable' + } + }) + ) + + expect(res2.status).toBe(422) + + const res3 = await appWithArray.handle( + post('/', { + name: { + value: ['1', '2', '3'] + } + }) + ) + + expect(res3.status).toBe(422) + + const appWithObj = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.MaybeNull( + t.Object({ + a: t.String(), + b: t.Number(), + c: t.Boolean() + }) + ) + }) + }) + + const res4 = await appWithObj.handle( + post('/', { + name: { + a: '1', + b: 2, + c: true + } + }) + ) + + expect(res4.status).toBe(200) + expect(await res4.json()).toEqual({ name: { a: '1', b: 2, c: true } }) + + const res5 = await appWithObj.handle( + post('/', { + name: { + a: '1', + b: '2', + c: true + } + }) + ) + + expect(res5.status).toBe(422) + + const res6 = await appWithObj.handle( + post('/', { + name: 'abc' + }) + ) + + expect(res6.status).toBe(422) + + const res7 = await appWithObj.handle( + post('/', { + name: null + }) + ) + + expect(res7.status).toBe(200) + expect(await res7.json()).toEqual({ name: null }) + + const res8 = await appWithObj.handle( + post('/', {}) + ) + + expect(res8.status).toBe(422) + }) +}) From 327e2348856d1e09c8850c9e20f637508a859078 Mon Sep 17 00:00:00 2001 From: Akim Mamedov Date: Tue, 2 Dec 2025 00:24:29 +0700 Subject: [PATCH 3/5] Test for default value --- src/type-system/index.ts | 5 +++-- test/type-system/maybe-null.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/type-system/index.ts b/src/type-system/index.ts index d4527281..a9037f6a 100644 --- a/src/type-system/index.ts +++ b/src/type-system/index.ts @@ -84,13 +84,14 @@ createType( 'MaybeNull', ({ [Kind]: kind, [WrappedKind]: wrappedKind, ...schema }, value) => { return ( + value === null || Value.Check( { [Kind]: wrappedKind, ...schema }, value - ) || value === null + ) ) } ) @@ -539,8 +540,8 @@ export const ElysiaType = { return { [Kind]: 'MaybeNull', [WrappedKind]: kind, - ...schema, default: undefined, + ...schema, nullable: true } as unknown as TMaybeNull }, diff --git a/test/type-system/maybe-null.test.ts b/test/type-system/maybe-null.test.ts index 97f0aefd..b2d57b39 100644 --- a/test/type-system/maybe-null.test.ts +++ b/test/type-system/maybe-null.test.ts @@ -213,4 +213,18 @@ describe('TypeSystem - MaybeNull', () => { expect(res8.status).toBe(422) }) + + it("Validates with default", async () => { + const appWithDefault = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.MaybeNull(t.Number({ + default: 1 + })) + }) + }); + + const res = await appWithDefault.handle(post('/', {})); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ name: 1 }); + }); }) From 18513306848b0cb6c497d3792158d96f3abbf7aa Mon Sep 17 00:00:00 2001 From: Akim Mamedov Date: Tue, 2 Dec 2025 00:52:12 +0700 Subject: [PATCH 4/5] Small improves --- src/type-system/index.ts | 9 +++++---- test/type-system/maybe-null.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/type-system/index.ts b/src/type-system/index.ts index a9037f6a..7c7777d0 100644 --- a/src/type-system/index.ts +++ b/src/type-system/index.ts @@ -1,8 +1,9 @@ -import type { +import { ArrayOptions, DateOptions, IntegerOptions, JavaScriptTypeBuilder, + Kind, NumberOptions, ObjectOptions, SchemaOptions, @@ -20,9 +21,9 @@ import type { TSchema, TString, TUnsafe, + Type, Uint8ArrayOptions } from '@sinclair/typebox' -import { Kind, Type } from '@sinclair/typebox' import { DefaultErrorFunction, SetErrorFunction @@ -99,7 +100,7 @@ createType( SetErrorFunction((error) => { switch (error.schema[Kind]) { case 'MaybeNull': - return `Expected either ${error.schema.type} or null` + return `Expected '${error.schema.type ?? error.schema[WrappedKind]}' or 'null'` default: return DefaultErrorFunction(error) } @@ -536,7 +537,7 @@ export const ElysiaType = { nullable: true }), - MaybeNull: ({ [Kind]: kind, ...schema }: T): TSchema => { + MaybeNull: ({ [Kind]: kind, ...schema }: T) => { return { [Kind]: 'MaybeNull', [WrappedKind]: kind, diff --git a/test/type-system/maybe-null.test.ts b/test/type-system/maybe-null.test.ts index b2d57b39..0adb3218 100644 --- a/test/type-system/maybe-null.test.ts +++ b/test/type-system/maybe-null.test.ts @@ -227,4 +227,20 @@ describe('TypeSystem - MaybeNull', () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ name: 1 }); }); + + it("Validates with correct error on complex types", async () => { + const appWithDefault = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.MaybeNull(t.ArrayBuffer()) + }) + }); + + const res = await appWithDefault.handle(post('/', { + name: 1 + })); + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ + message: "Expected 'ArrayBuffer' or 'null'" + }) + }); }) From d99b460f1bba58d516d5d12b32610798dfae01f0 Mon Sep 17 00:00:00 2001 From: Akim Mamedov Date: Tue, 2 Dec 2025 00:55:41 +0700 Subject: [PATCH 5/5] Ts error fix --- src/type-system/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/type-system/index.ts b/src/type-system/index.ts index 7c7777d0..01e04946 100644 --- a/src/type-system/index.ts +++ b/src/type-system/index.ts @@ -100,7 +100,7 @@ createType( SetErrorFunction((error) => { switch (error.schema[Kind]) { case 'MaybeNull': - return `Expected '${error.schema.type ?? error.schema[WrappedKind]}' or 'null'` + return `Expected '${error.schema.type ?? (error.schema as TMaybeNull)[WrappedKind]}' or 'null'` default: return DefaultErrorFunction(error) }