Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 61 additions & 12 deletions src/type-system/index.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
import { Type, Kind } from '@sinclair/typebox'
import type {
ArrayOptions,
DateOptions,
IntegerOptions,
JavaScriptTypeBuilder,
NumberOptions,
ObjectOptions,
SchemaOptions,
StringOptions,
TAnySchema,
TArray,
TBoolean,
TDate,
TEnum,
TEnumValue,
TInteger,
TNumber,
TObject,
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,
Expand All @@ -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,
Expand All @@ -73,6 +80,31 @@ createType<TArrayBuffer>(
(schema, value) => value instanceof ArrayBuffer
)

createType<TMaybeNull>(
'MaybeNull',
({ [Kind]: kind, [WrappedKind]: wrappedKind, ...schema }, value) => {
return (
value === null ||
Value.Check(
{
[Kind]: wrappedKind,
...schema
},
value
)
)
}
)

SetErrorFunction((error) => {
switch (error.schema[Kind]) {
case 'MaybeNull':
return `Expected either ${error.schema.type} or null`
default:
return DefaultErrorFunction(error)
}
})

const internalFiles = createType<FilesOptions, File[]>(
'Files',
(options, value) => {
Expand Down Expand Up @@ -153,7 +185,10 @@ export const ElysiaType = {
.Encode((value) => value) as any as TNumber
},

NumericEnum<T extends AssertNumericEnum<T>>(item: T, property?: SchemaOptions) {
NumericEnum<T extends AssertNumericEnum<T>>(
item: T,
property?: SchemaOptions
) {
const schema = Type.Enum(item, property)
const compiler = compile(schema)

Expand Down Expand Up @@ -492,12 +527,25 @@ export const ElysiaType = {
})
.Encode((value) => value) as unknown as TUnsafe<File[]>,

/**
* @deprecated Use MaybeNull instead which is OpenAPI 3.0 compliant. Will be removed in the next major release
*/
Nullable: <T extends TSchema>(schema: T, options?: SchemaOptions) =>
t.Union([schema, t.Null()], {
...options,
nullable: true
}),

MaybeNull: <T extends TSchema>({ [Kind]: kind, ...schema }: T): TSchema => {
return {
[Kind]: 'MaybeNull',
[WrappedKind]: kind,
default: undefined,
...schema,
nullable: true
} as unknown as TMaybeNull
},

/**
* Allow Optional, Nullable and Undefined
*/
Expand Down Expand Up @@ -660,6 +708,7 @@ t.Files = (arg) => {
}

t.Nullable = ElysiaType.Nullable
t.MaybeNull = ElysiaType.MaybeNull
t.MaybeEmpty = ElysiaType.MaybeEmpty
t.Cookie = ElysiaType.Cookie
t.Date = ElysiaType.Date
Expand Down
7 changes: 7 additions & 0 deletions src/type-system/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends TProperties = TProperties> = TUnsafe<
Expand Down
230 changes: 230 additions & 0 deletions test/type-system/maybe-null.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
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
}
}
})

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('Validates primitive values', async () => {
const app = new Elysia().post('/', ({ body }) => body, {
body: t.Object({
name: t.MaybeNull(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)

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)
})

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 });
});
})
Loading