From 1a044184f83d1b096ffd13a4b421a955642a78d7 Mon Sep 17 00:00:00 2001 From: Yam C Borodetsky Date: Sat, 17 Jan 2026 20:32:08 +0500 Subject: [PATCH 01/14] feat: Replace TypeBox with Zod as a dependency. --- apps/conduit/package.json | 7 +-- .../articles/dto/article-feed-query.dto.ts | 28 +++++------- .../src/articles/dto/article-response.dto.ts | 37 +++++++-------- .../src/articles/dto/articles-response.dto.ts | 11 ++--- .../src/articles/dto/create-article.dto.ts | 17 +++---- .../articles/dto/list-articles-query.dto.ts | 18 ++++---- .../src/articles/dto/update-article.dto.ts | 9 ++-- .../src/comments/comments.controller.ts | 17 +++---- .../src/comments/dto/comment-response.dto.ts | 26 +++++------ .../src/comments/dto/comments-response.dto.ts | 8 ++-- .../src/comments/dto/create-comment.dto.ts | 10 ++--- .../src/profiles/dto/profile-response.dto.ts | 16 +++---- .../conduit/src/shared/errors/errors.utils.ts | 30 ++++++++++--- .../conduit/src/tags/dto/tags-response.dto.ts | 8 ++-- apps/conduit/src/users/dto/create-user.dto.ts | 45 +++++++++---------- apps/conduit/src/users/dto/login-user.dto.ts | 17 +++---- apps/conduit/src/users/dto/update-user.dto.ts | 8 ++-- .../src/users/dto/user-response.dto.ts | 18 ++++---- bun.lock | 6 ++- 19 files changed, 169 insertions(+), 167 deletions(-) diff --git a/apps/conduit/package.json b/apps/conduit/package.json index 2f9308b3..12c22891 100644 --- a/apps/conduit/package.json +++ b/apps/conduit/package.json @@ -36,7 +36,6 @@ "@elysiajs/bearer": "^1.4.2", "@elysiajs/jwt": "^1.4.0", "@elysiajs/swagger": "^1.3.1", - "@sinclair/typebox": "0.34.47", "@yolk-oss/elysia-env": "^3.0.0", "chalk": "^5.6.2", "drizzle-orm": "^0.45.1", @@ -44,7 +43,8 @@ "http-status-codes": "^2.3.0", "jose": "^6.1.3", "postgres": "^3.4.8", - "radashi": "^12.7.1" + "radashi": "^12.7.1", + "zod": "^4.3.5" }, "devDependencies": { "@types/node": "^25.0.9", @@ -54,8 +54,5 @@ "drizzle-seed": "^0.3.1", "pg": "^8.16.3", "typescript": "catalog:" - }, - "overrides": { - "@sinclair/typebox": "0.34.34" } } diff --git a/apps/conduit/src/articles/dto/article-feed-query.dto.ts b/apps/conduit/src/articles/dto/article-feed-query.dto.ts index 0f23491c..9bf47572 100644 --- a/apps/conduit/src/articles/dto/article-feed-query.dto.ts +++ b/apps/conduit/src/articles/dto/article-feed-query.dto.ts @@ -1,4 +1,4 @@ -import { t } from 'elysia'; +import { z } from 'zod'; import { DEFAULT_LIMIT, DEFAULT_OFFSET, @@ -13,22 +13,14 @@ import { * - limit: number of items per request (default: DEFAULT_LIMIT, min: MIN_LIMIT, max: MAX_LIMIT) * - offset: number of items to skip (default: DEFAULT_OFFSET, min: MIN_OFFSET) */ -export const ArticleFeedQueryDto = t.Object({ - limit: t.Optional( - t.Integer({ - minimum: MIN_LIMIT, - maximum: MAX_LIMIT, - default: DEFAULT_LIMIT, - description: `Number of items per request (between ${MIN_LIMIT} and ${MAX_LIMIT}, defaults to ${DEFAULT_LIMIT})`, - }), - ), - offset: t.Optional( - t.Integer({ - minimum: MIN_OFFSET, - default: DEFAULT_OFFSET, - description: `Number of items to skip (at least ${MIN_OFFSET}, defaults to ${DEFAULT_OFFSET})`, - }), - ), +export const ArticleFeedQueryDto = z.object({ + limit: z.coerce + .number() + .int() + .min(MIN_LIMIT) + .max(MAX_LIMIT) + .default(DEFAULT_LIMIT), + offset: z.coerce.number().int().min(MIN_OFFSET).default(DEFAULT_OFFSET), }); -export type ArticleFeedQueryDto = typeof ArticleFeedQueryDto.static; +export type ArticleFeedQueryDto = z.infer; diff --git a/apps/conduit/src/articles/dto/article-response.dto.ts b/apps/conduit/src/articles/dto/article-response.dto.ts index 43badf80..6893a970 100644 --- a/apps/conduit/src/articles/dto/article-response.dto.ts +++ b/apps/conduit/src/articles/dto/article-response.dto.ts @@ -1,22 +1,23 @@ -import { t } from 'elysia'; +import { z } from 'zod'; -export const ArticleResponseDto = t.Object({ - article: t.Object({ - slug: t.String(), - title: t.String(), - description: t.String(), - body: t.String(), - tagList: t.Array(t.String()), - createdAt: t.String(), - updatedAt: t.String(), - favorited: t.Boolean(), - favoritesCount: t.Number(), - author: t.Object({ - username: t.String(), - bio: t.Union([t.Null(), t.String()]), - image: t.Union([t.Null(), t.String()]), - following: t.Boolean(), +export const ArticleResponseDto = z.object({ + article: z.object({ + slug: z.string(), + title: z.string(), + description: z.string(), + body: z.string(), + tagList: z.array(z.string()), + createdAt: z.string(), + updatedAt: z.string(), + favorited: z.boolean(), + favoritesCount: z.number(), + author: z.object({ + username: z.string(), + bio: z.string().nullable(), + image: z.string().nullable(), + following: z.boolean(), }), }), }); -export type ArticleResponseDto = typeof ArticleResponseDto.static; + +export type ArticleResponseDto = z.infer; diff --git a/apps/conduit/src/articles/dto/articles-response.dto.ts b/apps/conduit/src/articles/dto/articles-response.dto.ts index 695ae4b4..b2a672f6 100644 --- a/apps/conduit/src/articles/dto/articles-response.dto.ts +++ b/apps/conduit/src/articles/dto/articles-response.dto.ts @@ -1,8 +1,9 @@ -import { t } from 'elysia'; +import { z } from 'zod'; import { ArticleResponseDto } from './article-response.dto'; -export const ArticlesResponseDto = t.Object({ - articles: t.Array(t.Omit(ArticleResponseDto.properties.article, ['body'])), - articlesCount: t.Number(), +export const ArticlesResponseDto = z.object({ + articles: z.array(ArticleResponseDto.shape.article.omit({ body: true })), + articlesCount: z.number(), }); -export type ArticlesResponseDto = typeof ArticlesResponseDto.static; + +export type ArticlesResponseDto = z.infer; diff --git a/apps/conduit/src/articles/dto/create-article.dto.ts b/apps/conduit/src/articles/dto/create-article.dto.ts index def2dffe..2292fa43 100644 --- a/apps/conduit/src/articles/dto/create-article.dto.ts +++ b/apps/conduit/src/articles/dto/create-article.dto.ts @@ -1,11 +1,12 @@ -import { t } from 'elysia'; +import { z } from 'zod'; -export const CreateArticleDto = t.Object({ - article: t.Object({ - title: t.String({ minLength: 1 }), - description: t.String({ minLength: 1 }), - body: t.String({ minLength: 1 }), - tagList: t.Optional(t.Array(t.String({ minLength: 1 }))), +export const CreateArticleDto = z.object({ + article: z.object({ + title: z.string().min(1), + description: z.string().min(1), + body: z.string().min(1), + tagList: z.array(z.string().min(1)).optional(), }), }); -export type CreateArticleDto = typeof CreateArticleDto.static; + +export type CreateArticleDto = z.infer; diff --git a/apps/conduit/src/articles/dto/list-articles-query.dto.ts b/apps/conduit/src/articles/dto/list-articles-query.dto.ts index 42adf51e..dbef4d38 100644 --- a/apps/conduit/src/articles/dto/list-articles-query.dto.ts +++ b/apps/conduit/src/articles/dto/list-articles-query.dto.ts @@ -1,12 +1,10 @@ -import { t } from 'elysia'; +import { z } from 'zod'; import { ArticleFeedQueryDto } from './article-feed-query.dto'; -export const ListArticlesQueryDto = t.Composite([ - ArticleFeedQueryDto, - t.Object({ - tag: t.Optional(t.String({ minLength: 1 })), - author: t.Optional(t.String({ minLength: 1 })), - favorited: t.Optional(t.String({ minLength: 1 })), - }), -]); -export type ListArticlesQueryDto = typeof ListArticlesQueryDto.static; +export const ListArticlesQueryDto = ArticleFeedQueryDto.extend({ + tag: z.string().min(1).optional(), + author: z.string().min(1).optional(), + favorited: z.string().min(1).optional(), +}); + +export type ListArticlesQueryDto = z.infer; diff --git a/apps/conduit/src/articles/dto/update-article.dto.ts b/apps/conduit/src/articles/dto/update-article.dto.ts index d10a6552..4a6b5263 100644 --- a/apps/conduit/src/articles/dto/update-article.dto.ts +++ b/apps/conduit/src/articles/dto/update-article.dto.ts @@ -1,7 +1,8 @@ -import { t } from 'elysia'; +import { z } from 'zod'; import { CreateArticleDto } from './create-article.dto'; -export const UpdateArticleDto = t.Object({ - article: t.Partial(CreateArticleDto.properties.article), +export const UpdateArticleDto = z.object({ + article: CreateArticleDto.shape.article.partial(), }); -export type UpdateArticleDto = typeof UpdateArticleDto.static; + +export type UpdateArticleDto = z.infer; diff --git a/apps/conduit/src/comments/comments.controller.ts b/apps/conduit/src/comments/comments.controller.ts index 63d2cc55..31edc637 100644 --- a/apps/conduit/src/comments/comments.controller.ts +++ b/apps/conduit/src/comments/comments.controller.ts @@ -1,5 +1,6 @@ -import { Elysia, t } from 'elysia'; +import { Elysia } from 'elysia'; import { StatusCodes } from 'http-status-codes'; +import { z } from 'zod'; import { setupComments } from './comments.module'; import { CommentResponseDto, @@ -32,9 +33,7 @@ export const commentsController = new Elysia().use(setupComments).group( body: CreateCommentDto, response: { [StatusCodes.CREATED]: CommentResponseDto, - [StatusCodes.UNAUTHORIZED]: t.Void({ - description: 'Authentication required', - }), + [StatusCodes.UNAUTHORIZED]: z.void(), }, detail: { summary: 'Add Comments to an Article', @@ -79,14 +78,12 @@ export const commentsController = new Elysia().use(setupComments).group( }, { beforeHandle: app.store.authService.requireLogin, - params: t.Object({ - slug: t.String(), - id: t.Numeric(), + params: z.object({ + slug: z.string(), + id: z.coerce.number(), }), response: { - [StatusCodes.NO_CONTENT]: t.Void({ - description: 'No content', - }), + [StatusCodes.NO_CONTENT]: z.void(), }, detail: { summary: 'Delete Comment', diff --git a/apps/conduit/src/comments/dto/comment-response.dto.ts b/apps/conduit/src/comments/dto/comment-response.dto.ts index 8cf8f58a..b80bcb99 100644 --- a/apps/conduit/src/comments/dto/comment-response.dto.ts +++ b/apps/conduit/src/comments/dto/comment-response.dto.ts @@ -1,18 +1,18 @@ -import { t } from 'elysia'; +import { z } from 'zod'; -export const CommentResponseDto = t.Object({ - comment: t.Object({ - id: t.Number(), - body: t.String(), - createdAt: t.String(), - updatedAt: t.String(), - author: t.Object({ - username: t.String(), - bio: t.Union([t.Null(), t.String()]), - image: t.Union([t.Null(), t.String()]), - following: t.Boolean(), +export const CommentResponseDto = z.object({ + comment: z.object({ + id: z.number(), + body: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + author: z.object({ + username: z.string(), + bio: z.string().nullable(), + image: z.string().nullable(), + following: z.boolean(), }), }), }); -export type CommentResponseDto = typeof CommentResponseDto.static; +export type CommentResponseDto = z.infer; diff --git a/apps/conduit/src/comments/dto/comments-response.dto.ts b/apps/conduit/src/comments/dto/comments-response.dto.ts index e4b1d16b..8345da1c 100644 --- a/apps/conduit/src/comments/dto/comments-response.dto.ts +++ b/apps/conduit/src/comments/dto/comments-response.dto.ts @@ -1,7 +1,7 @@ -import { t } from 'elysia'; +import { z } from 'zod'; import { CommentResponseDto } from './comment-response.dto'; -export const CommentsResponseDto = t.Object({ - comments: t.Array(CommentResponseDto.properties.comment), +export const CommentsResponseDto = z.object({ + comments: z.array(CommentResponseDto.shape.comment), }); -export type CommentsResponseDto = typeof CommentsResponseDto.static; +export type CommentsResponseDto = z.infer; diff --git a/apps/conduit/src/comments/dto/create-comment.dto.ts b/apps/conduit/src/comments/dto/create-comment.dto.ts index 4c5015a0..0f1c6fa9 100644 --- a/apps/conduit/src/comments/dto/create-comment.dto.ts +++ b/apps/conduit/src/comments/dto/create-comment.dto.ts @@ -1,8 +1,8 @@ -import { t } from 'elysia'; +import { z } from 'zod'; -export const CreateCommentDto = t.Object({ - comment: t.Object({ - body: t.String({ minLength: 1 }), +export const CreateCommentDto = z.object({ + comment: z.object({ + body: z.string().min(1), }), }); -export type CreateCommentDto = typeof CreateCommentDto.static; +export type CreateCommentDto = z.infer; diff --git a/apps/conduit/src/profiles/dto/profile-response.dto.ts b/apps/conduit/src/profiles/dto/profile-response.dto.ts index bd58a95e..6467e1ab 100644 --- a/apps/conduit/src/profiles/dto/profile-response.dto.ts +++ b/apps/conduit/src/profiles/dto/profile-response.dto.ts @@ -1,12 +1,12 @@ -import { t } from 'elysia'; +import { z } from 'zod'; -export const profileResponseSchema = t.Object({ - profile: t.Object({ - username: t.String(), - bio: t.Union([t.String(), t.Null()]), - image: t.Union([t.String(), t.Null()]), - following: t.Boolean(), +export const profileResponseSchema = z.object({ + profile: z.object({ + username: z.string(), + bio: z.string().nullable(), + image: z.string().nullable(), + following: z.boolean(), }), }); -export type ProfileResponseDto = typeof profileResponseSchema.static; +export type ProfileResponseDto = z.infer; diff --git a/apps/conduit/src/shared/errors/errors.utils.ts b/apps/conduit/src/shared/errors/errors.utils.ts index 120a01fb..8f72186a 100644 --- a/apps/conduit/src/shared/errors/errors.utils.ts +++ b/apps/conduit/src/shared/errors/errors.utils.ts @@ -39,11 +39,31 @@ export function formatValidationError(error: ValidationError) { const result: Record = {}; for (const err of error.all) { - const path = 'path' in err ? parsePath(err.path) : 'general'; - let message = - 'schema' in err - ? (err.schema.description ?? err.summary ?? 'Invalid value') - : (err.summary ?? 'Invalid value'); + let path = 'general'; + if ('path' in err) { + if (Array.isArray(err.path)) { + path = err.path.join('.'); + } else if (typeof err.path === 'string') { + path = parsePath(err.path); + } + } + + let message: string; + if ('message' in err && typeof err.message === 'string') { + message = err.message; + } else if ( + 'schema' in err && + err.schema && + typeof err.schema === 'object' && + 'description' in err.schema && + typeof err.schema.description === 'string' + ) { + message = err.schema.description; + } else if ('summary' in err && typeof err.summary === 'string') { + message = err.summary; + } else { + message = 'Invalid value'; + } // 🧼 Remove redundant prefix: "Property 'user.image' should be ..." message = message.replace(/^Property '.*?' should /i, 'should '); diff --git a/apps/conduit/src/tags/dto/tags-response.dto.ts b/apps/conduit/src/tags/dto/tags-response.dto.ts index 5d7a5f52..8394373a 100644 --- a/apps/conduit/src/tags/dto/tags-response.dto.ts +++ b/apps/conduit/src/tags/dto/tags-response.dto.ts @@ -1,7 +1,7 @@ -import { t } from 'elysia'; +import { z } from 'zod'; -export const TagsResponseDto = t.Object({ - tags: t.Array(t.String()), +export const TagsResponseDto = z.object({ + tags: z.array(z.string()), }); -export type TagsResponseDto = typeof TagsResponseDto.static; +export type TagsResponseDto = z.infer; diff --git a/apps/conduit/src/users/dto/create-user.dto.ts b/apps/conduit/src/users/dto/create-user.dto.ts index 62df8516..43921074 100644 --- a/apps/conduit/src/users/dto/create-user.dto.ts +++ b/apps/conduit/src/users/dto/create-user.dto.ts @@ -1,30 +1,27 @@ -import { t } from 'elysia'; +import { z } from 'zod'; -export const CreateUserDto = t.Object({ - user: t.Object({ - email: t.String({ - format: 'email', - minLength: 3, - maxLength: 255, - description: 'must be a valid email address', - }), - password: t.String({ - minLength: 8, - maxLength: 100, - pattern: '^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d@$!%*?&]{8,}$', - description: +export const CreateUserDto = z.object({ + user: z.object({ + email: z.string().email('must be a valid email address').min(3).max(255), + password: z + .string() + .min(8) + .max(100) + .regex( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/, 'must be at least 8 characters and contain uppercase, lowercase, and numbers', - }), - username: t.String({ - minLength: 3, - maxLength: 50, - pattern: '^[a-zA-Z0-9_-]+$', - description: + ), + username: z + .string() + .min(3) + .max(50) + .regex( + /^[a-zA-Z0-9_-]+$/, 'must be 3-50 characters and contain only letters, numbers, underscores, and hyphens', - }), - bio: t.Optional(t.String({ maxLength: 1000 })), - image: t.Optional(t.String({ format: 'uri' })), + ), + bio: z.string().max(1000).nullish(), + image: z.string().url().nullish(), }), }); -export type CreateUserDto = typeof CreateUserDto.static; +export type CreateUserDto = z.infer; diff --git a/apps/conduit/src/users/dto/login-user.dto.ts b/apps/conduit/src/users/dto/login-user.dto.ts index 422c309e..db086725 100644 --- a/apps/conduit/src/users/dto/login-user.dto.ts +++ b/apps/conduit/src/users/dto/login-user.dto.ts @@ -1,15 +1,10 @@ -import { t } from 'elysia'; +import { z } from 'zod'; -export const LoginUserDto = t.Object({ - user: t.Object({ - email: t.String({ - format: 'email', - minLength: 3, - maxLength: 255, - description: 'must be a valid email address', - }), - password: t.String(), +export const LoginUserDto = z.object({ + user: z.object({ + email: z.string().email('must be a valid email address').min(3).max(255), + password: z.string(), }), }); -export type LoginUserDto = typeof LoginUserDto.static; +export type LoginUserDto = z.infer; diff --git a/apps/conduit/src/users/dto/update-user.dto.ts b/apps/conduit/src/users/dto/update-user.dto.ts index 43db2eab..8492e1f3 100644 --- a/apps/conduit/src/users/dto/update-user.dto.ts +++ b/apps/conduit/src/users/dto/update-user.dto.ts @@ -1,8 +1,8 @@ -import { t } from 'elysia'; +import { z } from 'zod'; import { CreateUserDto } from './create-user.dto'; -export const UpdateUserDto = t.Object({ - user: t.Partial(CreateUserDto.properties.user), +export const UpdateUserDto = z.object({ + user: CreateUserDto.shape.user.partial(), }); -export type UpdateUserDto = typeof UpdateUserDto.static; +export type UpdateUserDto = z.infer; diff --git a/apps/conduit/src/users/dto/user-response.dto.ts b/apps/conduit/src/users/dto/user-response.dto.ts index 64a577af..9f2eeaab 100644 --- a/apps/conduit/src/users/dto/user-response.dto.ts +++ b/apps/conduit/src/users/dto/user-response.dto.ts @@ -1,13 +1,13 @@ -import { t } from 'elysia'; +import { z } from 'zod'; -export const UserResponseDto = t.Object({ - user: t.Object({ - email: t.String(), - token: t.String(), - username: t.String(), - bio: t.Union([t.String(), t.Null()]), - image: t.Union([t.String(), t.Null()]), +export const UserResponseDto = z.object({ + user: z.object({ + email: z.string(), + token: z.string(), + username: z.string(), + bio: z.string().nullable(), + image: z.string().nullable(), }), }); -export type UserResponseDto = typeof UserResponseDto.static; +export type UserResponseDto = z.infer; diff --git a/bun.lock b/bun.lock index c87e50e4..6065aa38 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,6 @@ "@elysiajs/bearer": "^1.4.2", "@elysiajs/jwt": "^1.4.0", "@elysiajs/swagger": "^1.3.1", - "@sinclair/typebox": "0.34.47", "@yolk-oss/elysia-env": "^3.0.0", "chalk": "^5.6.2", "drizzle-orm": "^0.45.1", @@ -28,6 +27,7 @@ "jose": "^6.1.3", "postgres": "^3.4.8", "radashi": "^12.7.1", + "zod": "^4.3.5", }, "devDependencies": { "@types/node": "^25.0.9", @@ -796,7 +796,7 @@ "zhead": ["zhead@2.2.4", "https://registry.npmmirror.com/zhead/-/zhead-2.2.4.tgz", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="], - "zod": ["zod@3.25.64", "https://registry.npmmirror.com/zod/-/zod-3.25.64.tgz", {}, "sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g=="], + "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -868,6 +868,8 @@ "@scalar/themes/@scalar/types/type-fest": ["type-fest@4.41.0", "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "@scalar/themes/@scalar/types/zod": ["zod@3.25.64", "https://registry.npmmirror.com/zod/-/zod-3.25.64.tgz", {}, "sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g=="], + "bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], From 4a69c25fdab72720ec275b2b3e907602fc25842b Mon Sep 17 00:00:00 2001 From: Yam C Borodetsky Date: Sat, 17 Jan 2026 20:33:11 +0500 Subject: [PATCH 02/14] refactor: Replace Elysia's `t.Void` with Zod's `z.void()` for the `NO_CONTENT` response schema. --- apps/conduit/src/app.module.ts | 2 +- apps/conduit/src/articles/articles.controller.ts | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/conduit/src/app.module.ts b/apps/conduit/src/app.module.ts index 03df3026..2100ab0e 100644 --- a/apps/conduit/src/app.module.ts +++ b/apps/conduit/src/app.module.ts @@ -28,7 +28,7 @@ export const setupApp = () => { set.status = error.status; return pick(error, ['errors']); } - // Elysia validation errors (TypeBox based) + // Elysia validation errors (Zod or TypeBox based) if (error instanceof ValidationError) { return formatValidationError(error); } diff --git a/apps/conduit/src/articles/articles.controller.ts b/apps/conduit/src/articles/articles.controller.ts index 785f335d..04cf5543 100644 --- a/apps/conduit/src/articles/articles.controller.ts +++ b/apps/conduit/src/articles/articles.controller.ts @@ -1,5 +1,6 @@ -import { Elysia, t } from 'elysia'; +import { Elysia } from 'elysia'; import { StatusCodes } from 'http-status-codes'; +import { z } from 'zod'; import { setupArticles } from '@/articles/articles.module'; import { DEFAULT_LIMIT, DEFAULT_OFFSET } from '@/shared/constants'; import { @@ -173,9 +174,7 @@ export const articlesController = new Elysia().use(setupArticles).group( { beforeHandle: app.store.authService.requireLogin, response: { - [StatusCodes.NO_CONTENT]: t.Void({ - description: 'No content', - }), + [StatusCodes.NO_CONTENT]: z.void(), }, detail: { summary: 'Delete Article', From a1b269c3bd2d8b70acdacbef8b884b072536e675 Mon Sep 17 00:00:00 2001 From: Yam C Borodetsky Date: Sat, 17 Jan 2026 20:36:29 +0500 Subject: [PATCH 03/14] refactor: replace Zod with Arktype for schema validation. --- apps/conduit/package.json | 4 +- apps/conduit/src/app.module.ts | 2 +- .../src/articles/articles.controller.ts | 3 +- .../articles/dto/article-feed-query.dto.ts | 15 +++---- .../src/articles/dto/article-response.dto.ts | 40 +++++++++---------- .../src/articles/dto/articles-response.dto.ts | 10 ++--- .../src/articles/dto/create-article.dto.ts | 18 ++++----- .../articles/dto/list-articles-query.dto.ts | 12 +++--- .../src/articles/dto/update-article.dto.ts | 8 ++-- .../src/comments/comments.controller.ts | 12 +++--- .../src/comments/dto/comment-response.dto.ts | 30 +++++++------- .../src/comments/dto/comments-response.dto.ts | 9 +++-- .../src/comments/dto/create-comment.dto.ts | 13 +++--- .../src/profiles/dto/profile-response.dto.ts | 18 ++++----- .../conduit/src/tags/dto/tags-response.dto.ts | 8 ++-- apps/conduit/src/users/dto/create-user.dto.ts | 38 ++++++++---------- apps/conduit/src/users/dto/login-user.dto.ts | 14 +++---- apps/conduit/src/users/dto/update-user.dto.ts | 8 ++-- .../src/users/dto/user-response.dto.ts | 20 +++++----- bun.lock | 14 +++++-- 20 files changed, 146 insertions(+), 150 deletions(-) diff --git a/apps/conduit/package.json b/apps/conduit/package.json index 12c22891..d82fffde 100644 --- a/apps/conduit/package.json +++ b/apps/conduit/package.json @@ -37,14 +37,14 @@ "@elysiajs/jwt": "^1.4.0", "@elysiajs/swagger": "^1.3.1", "@yolk-oss/elysia-env": "^3.0.0", + "arktype": "^2.1.29", "chalk": "^5.6.2", "drizzle-orm": "^0.45.1", "elysia": "^1.4.21", "http-status-codes": "^2.3.0", "jose": "^6.1.3", "postgres": "^3.4.8", - "radashi": "^12.7.1", - "zod": "^4.3.5" + "radashi": "^12.7.1" }, "devDependencies": { "@types/node": "^25.0.9", diff --git a/apps/conduit/src/app.module.ts b/apps/conduit/src/app.module.ts index 2100ab0e..deb0a61e 100644 --- a/apps/conduit/src/app.module.ts +++ b/apps/conduit/src/app.module.ts @@ -28,7 +28,7 @@ export const setupApp = () => { set.status = error.status; return pick(error, ['errors']); } - // Elysia validation errors (Zod or TypeBox based) + // Elysia validation errors (ArkType based) if (error instanceof ValidationError) { return formatValidationError(error); } diff --git a/apps/conduit/src/articles/articles.controller.ts b/apps/conduit/src/articles/articles.controller.ts index 04cf5543..dbc44441 100644 --- a/apps/conduit/src/articles/articles.controller.ts +++ b/apps/conduit/src/articles/articles.controller.ts @@ -1,6 +1,5 @@ import { Elysia } from 'elysia'; import { StatusCodes } from 'http-status-codes'; -import { z } from 'zod'; import { setupArticles } from '@/articles/articles.module'; import { DEFAULT_LIMIT, DEFAULT_OFFSET } from '@/shared/constants'; import { @@ -174,7 +173,7 @@ export const articlesController = new Elysia().use(setupArticles).group( { beforeHandle: app.store.authService.requireLogin, response: { - [StatusCodes.NO_CONTENT]: z.void(), + [StatusCodes.NO_CONTENT]: 'void', }, detail: { summary: 'Delete Article', diff --git a/apps/conduit/src/articles/dto/article-feed-query.dto.ts b/apps/conduit/src/articles/dto/article-feed-query.dto.ts index 9bf47572..b4d5a87d 100644 --- a/apps/conduit/src/articles/dto/article-feed-query.dto.ts +++ b/apps/conduit/src/articles/dto/article-feed-query.dto.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { type } from 'arktype'; import { DEFAULT_LIMIT, DEFAULT_OFFSET, @@ -13,14 +13,9 @@ import { * - limit: number of items per request (default: DEFAULT_LIMIT, min: MIN_LIMIT, max: MAX_LIMIT) * - offset: number of items to skip (default: DEFAULT_OFFSET, min: MIN_OFFSET) */ -export const ArticleFeedQueryDto = z.object({ - limit: z.coerce - .number() - .int() - .min(MIN_LIMIT) - .max(MAX_LIMIT) - .default(DEFAULT_LIMIT), - offset: z.coerce.number().int().min(MIN_OFFSET).default(DEFAULT_OFFSET), +export const ArticleFeedQueryDto = type({ + 'limit?': `${MIN_LIMIT} <= number.integer <= ${MAX_LIMIT} = ${DEFAULT_LIMIT}`, + 'offset?': `number.integer >= ${MIN_OFFSET} = ${DEFAULT_OFFSET}`, }); -export type ArticleFeedQueryDto = z.infer; +export type ArticleFeedQueryDto = typeof ArticleFeedQueryDto.infer; diff --git a/apps/conduit/src/articles/dto/article-response.dto.ts b/apps/conduit/src/articles/dto/article-response.dto.ts index 6893a970..ab24e3d5 100644 --- a/apps/conduit/src/articles/dto/article-response.dto.ts +++ b/apps/conduit/src/articles/dto/article-response.dto.ts @@ -1,23 +1,23 @@ -import { z } from 'zod'; +import { type } from 'arktype'; -export const ArticleResponseDto = z.object({ - article: z.object({ - slug: z.string(), - title: z.string(), - description: z.string(), - body: z.string(), - tagList: z.array(z.string()), - createdAt: z.string(), - updatedAt: z.string(), - favorited: z.boolean(), - favoritesCount: z.number(), - author: z.object({ - username: z.string(), - bio: z.string().nullable(), - image: z.string().nullable(), - following: z.boolean(), - }), - }), +export const ArticleResponseDto = type({ + article: { + slug: 'string', + title: 'string', + description: 'string', + body: 'string', + tagList: 'string[]', + createdAt: 'string', + updatedAt: 'string', + favorited: 'boolean', + favoritesCount: 'number', + author: { + username: 'string', + 'bio?': 'string | null', + 'image?': 'string | null', + following: 'boolean', + }, + }, }); -export type ArticleResponseDto = z.infer; +export type ArticleResponseDto = typeof ArticleResponseDto.infer; diff --git a/apps/conduit/src/articles/dto/articles-response.dto.ts b/apps/conduit/src/articles/dto/articles-response.dto.ts index b2a672f6..586a293e 100644 --- a/apps/conduit/src/articles/dto/articles-response.dto.ts +++ b/apps/conduit/src/articles/dto/articles-response.dto.ts @@ -1,9 +1,9 @@ -import { z } from 'zod'; +import { type } from 'arktype'; import { ArticleResponseDto } from './article-response.dto'; -export const ArticlesResponseDto = z.object({ - articles: z.array(ArticleResponseDto.shape.article.omit({ body: true })), - articlesCount: z.number(), +export const ArticlesResponseDto = type({ + articles: ArticleResponseDto.get('article').omit('body').array(), + articlesCount: 'number', }); -export type ArticlesResponseDto = z.infer; +export type ArticlesResponseDto = typeof ArticlesResponseDto.infer; diff --git a/apps/conduit/src/articles/dto/create-article.dto.ts b/apps/conduit/src/articles/dto/create-article.dto.ts index 2292fa43..80617815 100644 --- a/apps/conduit/src/articles/dto/create-article.dto.ts +++ b/apps/conduit/src/articles/dto/create-article.dto.ts @@ -1,12 +1,12 @@ -import { z } from 'zod'; +import { type } from 'arktype'; -export const CreateArticleDto = z.object({ - article: z.object({ - title: z.string().min(1), - description: z.string().min(1), - body: z.string().min(1), - tagList: z.array(z.string().min(1)).optional(), - }), +export const CreateArticleDto = type({ + article: { + title: 'string > 0', + description: 'string > 0', + body: 'string > 0', + 'tagList?': 'string[]', + }, }); -export type CreateArticleDto = z.infer; +export type CreateArticleDto = typeof CreateArticleDto.infer; diff --git a/apps/conduit/src/articles/dto/list-articles-query.dto.ts b/apps/conduit/src/articles/dto/list-articles-query.dto.ts index dbef4d38..67920db8 100644 --- a/apps/conduit/src/articles/dto/list-articles-query.dto.ts +++ b/apps/conduit/src/articles/dto/list-articles-query.dto.ts @@ -1,10 +1,10 @@ -import { z } from 'zod'; +import { type } from 'arktype'; import { ArticleFeedQueryDto } from './article-feed-query.dto'; -export const ListArticlesQueryDto = ArticleFeedQueryDto.extend({ - tag: z.string().min(1).optional(), - author: z.string().min(1).optional(), - favorited: z.string().min(1).optional(), +export const ListArticlesQueryDto = ArticleFeedQueryDto.merge({ + 'tag?': 'string > 0', + 'author?': 'string > 0', + 'favorited?': 'string > 0', }); -export type ListArticlesQueryDto = z.infer; +export type ListArticlesQueryDto = typeof ListArticlesQueryDto.infer; diff --git a/apps/conduit/src/articles/dto/update-article.dto.ts b/apps/conduit/src/articles/dto/update-article.dto.ts index 4a6b5263..4b8e754a 100644 --- a/apps/conduit/src/articles/dto/update-article.dto.ts +++ b/apps/conduit/src/articles/dto/update-article.dto.ts @@ -1,8 +1,8 @@ -import { z } from 'zod'; +import { type } from 'arktype'; import { CreateArticleDto } from './create-article.dto'; -export const UpdateArticleDto = z.object({ - article: CreateArticleDto.shape.article.partial(), +export const UpdateArticleDto = type({ + article: CreateArticleDto.get('article').partial(), }); -export type UpdateArticleDto = z.infer; +export type UpdateArticleDto = typeof UpdateArticleDto.infer; diff --git a/apps/conduit/src/comments/comments.controller.ts b/apps/conduit/src/comments/comments.controller.ts index 31edc637..e98359e8 100644 --- a/apps/conduit/src/comments/comments.controller.ts +++ b/apps/conduit/src/comments/comments.controller.ts @@ -1,6 +1,6 @@ +import { type } from 'arktype'; import { Elysia } from 'elysia'; import { StatusCodes } from 'http-status-codes'; -import { z } from 'zod'; import { setupComments } from './comments.module'; import { CommentResponseDto, @@ -33,7 +33,7 @@ export const commentsController = new Elysia().use(setupComments).group( body: CreateCommentDto, response: { [StatusCodes.CREATED]: CommentResponseDto, - [StatusCodes.UNAUTHORIZED]: z.void(), + [StatusCodes.UNAUTHORIZED]: 'void', }, detail: { summary: 'Add Comments to an Article', @@ -78,12 +78,12 @@ export const commentsController = new Elysia().use(setupComments).group( }, { beforeHandle: app.store.authService.requireLogin, - params: z.object({ - slug: z.string(), - id: z.coerce.number(), + params: type({ + slug: 'string', + id: 'string.numeric.parse', }), response: { - [StatusCodes.NO_CONTENT]: z.void(), + [StatusCodes.NO_CONTENT]: 'void', }, detail: { summary: 'Delete Comment', diff --git a/apps/conduit/src/comments/dto/comment-response.dto.ts b/apps/conduit/src/comments/dto/comment-response.dto.ts index b80bcb99..373f449b 100644 --- a/apps/conduit/src/comments/dto/comment-response.dto.ts +++ b/apps/conduit/src/comments/dto/comment-response.dto.ts @@ -1,18 +1,18 @@ -import { z } from 'zod'; +import { type } from 'arktype'; -export const CommentResponseDto = z.object({ - comment: z.object({ - id: z.number(), - body: z.string(), - createdAt: z.string(), - updatedAt: z.string(), - author: z.object({ - username: z.string(), - bio: z.string().nullable(), - image: z.string().nullable(), - following: z.boolean(), - }), - }), +export const CommentResponseDto = type({ + comment: { + id: 'number', + body: 'string', + createdAt: 'string', + updatedAt: 'string', + author: { + username: 'string', + 'bio?': 'string | null', + 'image?': 'string | null', + following: 'boolean', + }, + }, }); -export type CommentResponseDto = z.infer; +export type CommentResponseDto = typeof CommentResponseDto.infer; diff --git a/apps/conduit/src/comments/dto/comments-response.dto.ts b/apps/conduit/src/comments/dto/comments-response.dto.ts index 8345da1c..09e241be 100644 --- a/apps/conduit/src/comments/dto/comments-response.dto.ts +++ b/apps/conduit/src/comments/dto/comments-response.dto.ts @@ -1,7 +1,8 @@ -import { z } from 'zod'; +import { type } from 'arktype'; import { CommentResponseDto } from './comment-response.dto'; -export const CommentsResponseDto = z.object({ - comments: z.array(CommentResponseDto.shape.comment), +export const CommentsResponseDto = type({ + comments: CommentResponseDto.get('comment').array(), }); -export type CommentsResponseDto = z.infer; + +export type CommentsResponseDto = typeof CommentsResponseDto.infer; diff --git a/apps/conduit/src/comments/dto/create-comment.dto.ts b/apps/conduit/src/comments/dto/create-comment.dto.ts index 0f1c6fa9..b0502c50 100644 --- a/apps/conduit/src/comments/dto/create-comment.dto.ts +++ b/apps/conduit/src/comments/dto/create-comment.dto.ts @@ -1,8 +1,9 @@ -import { z } from 'zod'; +import { type } from 'arktype'; -export const CreateCommentDto = z.object({ - comment: z.object({ - body: z.string().min(1), - }), +export const CreateCommentDto = type({ + comment: { + body: 'string > 0', + }, }); -export type CreateCommentDto = z.infer; + +export type CreateCommentDto = typeof CreateCommentDto.infer; diff --git a/apps/conduit/src/profiles/dto/profile-response.dto.ts b/apps/conduit/src/profiles/dto/profile-response.dto.ts index 6467e1ab..08c02063 100644 --- a/apps/conduit/src/profiles/dto/profile-response.dto.ts +++ b/apps/conduit/src/profiles/dto/profile-response.dto.ts @@ -1,12 +1,12 @@ -import { z } from 'zod'; +import { type } from 'arktype'; -export const profileResponseSchema = z.object({ - profile: z.object({ - username: z.string(), - bio: z.string().nullable(), - image: z.string().nullable(), - following: z.boolean(), - }), +export const profileResponseSchema = type({ + profile: { + username: 'string', + 'bio?': 'string | null', + 'image?': 'string | null', + following: 'boolean', + }, }); -export type ProfileResponseDto = z.infer; +export type ProfileResponseDto = typeof profileResponseSchema.infer; diff --git a/apps/conduit/src/tags/dto/tags-response.dto.ts b/apps/conduit/src/tags/dto/tags-response.dto.ts index 8394373a..ab5d1275 100644 --- a/apps/conduit/src/tags/dto/tags-response.dto.ts +++ b/apps/conduit/src/tags/dto/tags-response.dto.ts @@ -1,7 +1,7 @@ -import { z } from 'zod'; +import { type } from 'arktype'; -export const TagsResponseDto = z.object({ - tags: z.array(z.string()), +export const TagsResponseDto = type({ + tags: 'string[]', }); -export type TagsResponseDto = z.infer; +export type TagsResponseDto = typeof TagsResponseDto.infer; diff --git a/apps/conduit/src/users/dto/create-user.dto.ts b/apps/conduit/src/users/dto/create-user.dto.ts index 43921074..df0bb2c6 100644 --- a/apps/conduit/src/users/dto/create-user.dto.ts +++ b/apps/conduit/src/users/dto/create-user.dto.ts @@ -1,27 +1,21 @@ -import { z } from 'zod'; +import { type } from 'arktype'; -export const CreateUserDto = z.object({ - user: z.object({ - email: z.string().email('must be a valid email address').min(3).max(255), - password: z - .string() - .min(8) - .max(100) - .regex( - /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/, - 'must be at least 8 characters and contain uppercase, lowercase, and numbers', +export const CreateUserDto = type({ + user: { + email: 'string.email', + password: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/ + .and('8 <= string <= 100') + .describe( + 'at least 8 characters and contain uppercase, lowercase, and numbers', ), - username: z - .string() - .min(3) - .max(50) - .regex( - /^[a-zA-Z0-9_-]+$/, - 'must be 3-50 characters and contain only letters, numbers, underscores, and hyphens', + username: /^[a-zA-Z0-9_-]+$/ + .and('3 <= string <= 50') + .describe( + '3-50 characters and contain only letters, numbers, underscores, and hyphens', ), - bio: z.string().max(1000).nullish(), - image: z.string().url().nullish(), - }), + 'bio?': 'string <= 1000', + 'image?': 'string.url', + }, }); -export type CreateUserDto = z.infer; +export type CreateUserDto = typeof CreateUserDto.infer; diff --git a/apps/conduit/src/users/dto/login-user.dto.ts b/apps/conduit/src/users/dto/login-user.dto.ts index db086725..01bb63e1 100644 --- a/apps/conduit/src/users/dto/login-user.dto.ts +++ b/apps/conduit/src/users/dto/login-user.dto.ts @@ -1,10 +1,10 @@ -import { z } from 'zod'; +import { type } from 'arktype'; -export const LoginUserDto = z.object({ - user: z.object({ - email: z.string().email('must be a valid email address').min(3).max(255), - password: z.string(), - }), +export const LoginUserDto = type({ + user: { + email: 'string.email', + password: 'string', + }, }); -export type LoginUserDto = z.infer; +export type LoginUserDto = typeof LoginUserDto.infer; diff --git a/apps/conduit/src/users/dto/update-user.dto.ts b/apps/conduit/src/users/dto/update-user.dto.ts index 8492e1f3..1e56d3b1 100644 --- a/apps/conduit/src/users/dto/update-user.dto.ts +++ b/apps/conduit/src/users/dto/update-user.dto.ts @@ -1,8 +1,8 @@ -import { z } from 'zod'; +import { type } from 'arktype'; import { CreateUserDto } from './create-user.dto'; -export const UpdateUserDto = z.object({ - user: CreateUserDto.shape.user.partial(), +export const UpdateUserDto = type({ + user: CreateUserDto.get('user').partial(), }); -export type UpdateUserDto = z.infer; +export type UpdateUserDto = typeof UpdateUserDto.infer; diff --git a/apps/conduit/src/users/dto/user-response.dto.ts b/apps/conduit/src/users/dto/user-response.dto.ts index 9f2eeaab..681e4537 100644 --- a/apps/conduit/src/users/dto/user-response.dto.ts +++ b/apps/conduit/src/users/dto/user-response.dto.ts @@ -1,13 +1,13 @@ -import { z } from 'zod'; +import { type } from 'arktype'; -export const UserResponseDto = z.object({ - user: z.object({ - email: z.string(), - token: z.string(), - username: z.string(), - bio: z.string().nullable(), - image: z.string().nullable(), - }), +export const UserResponseDto = type({ + user: { + email: 'string', + token: 'string', + username: 'string', + 'bio?': 'string | null', + 'image?': 'string | null', + }, }); -export type UserResponseDto = z.infer; +export type UserResponseDto = typeof UserResponseDto.infer; diff --git a/bun.lock b/bun.lock index 6065aa38..9d9206c0 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "@elysiajs/jwt": "^1.4.0", "@elysiajs/swagger": "^1.3.1", "@yolk-oss/elysia-env": "^3.0.0", + "arktype": "^2.1.29", "chalk": "^5.6.2", "drizzle-orm": "^0.45.1", "elysia": "^1.4.21", @@ -27,7 +28,6 @@ "jose": "^6.1.3", "postgres": "^3.4.8", "radashi": "^12.7.1", - "zod": "^4.3.5", }, "devDependencies": { "@types/node": "^25.0.9", @@ -96,6 +96,10 @@ "@algolia/requester-node-http": ["@algolia/requester-node-http@5.46.3", "", { "dependencies": { "@algolia/client-common": "5.46.3" } }, "sha512-4No9iTjr1GZ0JWsFbQJj9aZBnmKyY1sTxOoEud9+SGe3U6iAulF0A0lI4cWi/F/Gcfg8V3jkaddcqSQKDnE45w=="], + "@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="], + + "@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], @@ -366,6 +370,10 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "arkregex": ["arkregex@0.0.5", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw=="], + + "arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], @@ -796,7 +804,7 @@ "zhead": ["zhead@2.2.4", "https://registry.npmmirror.com/zhead/-/zhead-2.2.4.tgz", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="], - "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "zod": ["zod@3.25.64", "https://registry.npmmirror.com/zod/-/zod-3.25.64.tgz", {}, "sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -868,8 +876,6 @@ "@scalar/themes/@scalar/types/type-fest": ["type-fest@4.41.0", "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - "@scalar/themes/@scalar/types/zod": ["zod@3.25.64", "https://registry.npmmirror.com/zod/-/zod-3.25.64.tgz", {}, "sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g=="], - "bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], From 75c26328bfc365574b802a08768ba4aa09cc523d Mon Sep 17 00:00:00 2001 From: Yam C Borodetsky Date: Sat, 17 Jan 2026 20:42:12 +0500 Subject: [PATCH 04/14] feat: Update Arktype response types from 'void' to 'type("undefined")', refine query DTOs, and explicitly cast comment ID to number. --- apps/conduit/src/articles/articles.controller.ts | 3 ++- .../src/articles/dto/article-feed-query.dto.ts | 4 ++-- .../src/articles/dto/list-articles-query.dto.ts | 1 - apps/conduit/src/comments/comments.controller.ts | 6 +++--- apps/conduit/src/shared/errors/errors.utils.ts | 11 +++++++---- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/conduit/src/articles/articles.controller.ts b/apps/conduit/src/articles/articles.controller.ts index dbc44441..f024cb72 100644 --- a/apps/conduit/src/articles/articles.controller.ts +++ b/apps/conduit/src/articles/articles.controller.ts @@ -1,3 +1,4 @@ +import { type } from 'arktype'; import { Elysia } from 'elysia'; import { StatusCodes } from 'http-status-codes'; import { setupArticles } from '@/articles/articles.module'; @@ -173,7 +174,7 @@ export const articlesController = new Elysia().use(setupArticles).group( { beforeHandle: app.store.authService.requireLogin, response: { - [StatusCodes.NO_CONTENT]: 'void', + [StatusCodes.NO_CONTENT]: type('undefined'), }, detail: { summary: 'Delete Article', diff --git a/apps/conduit/src/articles/dto/article-feed-query.dto.ts b/apps/conduit/src/articles/dto/article-feed-query.dto.ts index b4d5a87d..0a3d7ea6 100644 --- a/apps/conduit/src/articles/dto/article-feed-query.dto.ts +++ b/apps/conduit/src/articles/dto/article-feed-query.dto.ts @@ -14,8 +14,8 @@ import { * - offset: number of items to skip (default: DEFAULT_OFFSET, min: MIN_OFFSET) */ export const ArticleFeedQueryDto = type({ - 'limit?': `${MIN_LIMIT} <= number.integer <= ${MAX_LIMIT} = ${DEFAULT_LIMIT}`, - 'offset?': `number.integer >= ${MIN_OFFSET} = ${DEFAULT_OFFSET}`, + limit: `${MIN_LIMIT} <= number.integer <= ${MAX_LIMIT} = ${DEFAULT_LIMIT}`, + offset: `number.integer >= ${MIN_OFFSET} = ${DEFAULT_OFFSET}`, }); export type ArticleFeedQueryDto = typeof ArticleFeedQueryDto.infer; diff --git a/apps/conduit/src/articles/dto/list-articles-query.dto.ts b/apps/conduit/src/articles/dto/list-articles-query.dto.ts index 67920db8..74477174 100644 --- a/apps/conduit/src/articles/dto/list-articles-query.dto.ts +++ b/apps/conduit/src/articles/dto/list-articles-query.dto.ts @@ -1,4 +1,3 @@ -import { type } from 'arktype'; import { ArticleFeedQueryDto } from './article-feed-query.dto'; export const ListArticlesQueryDto = ArticleFeedQueryDto.merge({ diff --git a/apps/conduit/src/comments/comments.controller.ts b/apps/conduit/src/comments/comments.controller.ts index e98359e8..1902f97d 100644 --- a/apps/conduit/src/comments/comments.controller.ts +++ b/apps/conduit/src/comments/comments.controller.ts @@ -33,7 +33,7 @@ export const commentsController = new Elysia().use(setupComments).group( body: CreateCommentDto, response: { [StatusCodes.CREATED]: CommentResponseDto, - [StatusCodes.UNAUTHORIZED]: 'void', + [StatusCodes.UNAUTHORIZED]: type('undefined'), }, detail: { summary: 'Add Comments to an Article', @@ -71,7 +71,7 @@ export const commentsController = new Elysia().use(setupComments).group( async ({ params, store, request, set }) => { await store.commentsService.deleteComment( params.slug, - params.id, + Number(params.id), await store.authService.getUserIdFromHeader(request.headers), ); set.status = StatusCodes.NO_CONTENT; @@ -83,7 +83,7 @@ export const commentsController = new Elysia().use(setupComments).group( id: 'string.numeric.parse', }), response: { - [StatusCodes.NO_CONTENT]: 'void', + [StatusCodes.NO_CONTENT]: type('undefined'), }, detail: { summary: 'Delete Comment', diff --git a/apps/conduit/src/shared/errors/errors.utils.ts b/apps/conduit/src/shared/errors/errors.utils.ts index 8f72186a..452f4a91 100644 --- a/apps/conduit/src/shared/errors/errors.utils.ts +++ b/apps/conduit/src/shared/errors/errors.utils.ts @@ -68,11 +68,14 @@ export function formatValidationError(error: ValidationError) { // 🧼 Remove redundant prefix: "Property 'user.image' should be ..." message = message.replace(/^Property '.*?' should /i, 'should '); - if (!result[path]) { - result[path] = []; + if (path) { + let messages = result[path]; + if (!messages) { + messages = []; + result[path] = messages; + } + messages.push(message); } - - result[path].push(message); } // Remove duplicates in each path’s messages From b1c9a83cdbf0d5b239d3351968a24069ebc4c5ca Mon Sep 17 00:00:00 2001 From: Yam C Borodetsky Date: Sat, 17 Jan 2026 20:45:03 +0500 Subject: [PATCH 05/14] refactor: Wrap DTO regexes in `type()` calls for schema validation and add a 10-second timeout to `curl` in API tests. --- apps/conduit/package.json | 1 + apps/conduit/src/users/dto/create-user.dto.ts | 4 ++-- bun.lock | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/conduit/package.json b/apps/conduit/package.json index d82fffde..2d4cc342 100644 --- a/apps/conduit/package.json +++ b/apps/conduit/package.json @@ -37,6 +37,7 @@ "@elysiajs/jwt": "^1.4.0", "@elysiajs/swagger": "^1.3.1", "@yolk-oss/elysia-env": "^3.0.0", + "arkregex": "^0.0.5", "arktype": "^2.1.29", "chalk": "^5.6.2", "drizzle-orm": "^0.45.1", diff --git a/apps/conduit/src/users/dto/create-user.dto.ts b/apps/conduit/src/users/dto/create-user.dto.ts index df0bb2c6..1349781e 100644 --- a/apps/conduit/src/users/dto/create-user.dto.ts +++ b/apps/conduit/src/users/dto/create-user.dto.ts @@ -3,12 +3,12 @@ import { type } from 'arktype'; export const CreateUserDto = type({ user: { email: 'string.email', - password: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/ + password: type(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/) .and('8 <= string <= 100') .describe( 'at least 8 characters and contain uppercase, lowercase, and numbers', ), - username: /^[a-zA-Z0-9_-]+$/ + username: type(/^[a-zA-Z0-9_-]+$/) .and('3 <= string <= 50') .describe( '3-50 characters and contain only letters, numbers, underscores, and hyphens', diff --git a/bun.lock b/bun.lock index 9d9206c0..55023f76 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "@elysiajs/jwt": "^1.4.0", "@elysiajs/swagger": "^1.3.1", "@yolk-oss/elysia-env": "^3.0.0", + "arkregex": "^0.0.5", "arktype": "^2.1.29", "chalk": "^5.6.2", "drizzle-orm": "^0.45.1", From a60b79251c611fc940dc81aada37f632dd6063e4 Mon Sep 17 00:00:00 2001 From: Yam C Borodetsky Date: Sat, 17 Jan 2026 20:48:29 +0500 Subject: [PATCH 06/14] refactor: Use `arkregex` for defining regex patterns in `CreateUserDto`. --- apps/conduit/src/users/dto/create-user.dto.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/conduit/src/users/dto/create-user.dto.ts b/apps/conduit/src/users/dto/create-user.dto.ts index 1349781e..3c434187 100644 --- a/apps/conduit/src/users/dto/create-user.dto.ts +++ b/apps/conduit/src/users/dto/create-user.dto.ts @@ -1,14 +1,17 @@ +import { regex } from 'arkregex'; import { type } from 'arktype'; export const CreateUserDto = type({ user: { email: 'string.email', - password: type(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/) + password: type( + regex('^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d@$!%*?&]{8,}$'), + ) .and('8 <= string <= 100') .describe( 'at least 8 characters and contain uppercase, lowercase, and numbers', ), - username: type(/^[a-zA-Z0-9_-]+$/) + username: type(regex('^[a-zA-Z0-9_-]+$')) .and('3 <= string <= 50') .describe( '3-50 characters and contain only letters, numbers, underscores, and hyphens', From 0ef5f81e0cc5929827bdadc3c06766fe53ee1198 Mon Sep 17 00:00:00 2001 From: Yam C Borodetsky Date: Sat, 17 Jan 2026 21:15:10 +0500 Subject: [PATCH 07/14] feat: Add ArkType as a core Bedstack component and update all related documentation and branding. --- CONTRIBUTING.md | 10 ++++++---- README.md | 4 ++-- apps/conduit/README.md | 2 +- apps/www/getting-started.md | 2 +- apps/www/index.md | 8 +++++++- apps/www/what-is-bedstack.md | 35 +++++++++++++++++++++++------------ package.json | 2 +- 7 files changed, 41 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6b4df07..b4f03959 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,13 +7,13 @@ Hey there! We're thrilled that you'd like to contribute to this project. Your he ## 👨‍💻 Repository Setup -This project uses [Bun](https://bun.sh) as a runtime as well as a package manager. It's a modern, fast, and lightweight alternative to [Node.js](https://nodejs.org/en/) and [npm](https://www.npmjs.com/). To install Bun on POSIX systems (like Ubuntu or macOS), run the following command: +This project uses [Bun](https://bun.com) as a runtime as well as a package manager. It's a modern, fast, and lightweight alternative to [Node.js](https://nodejs.org/en/) and [npm](https://www.npmjs.com/). To install Bun on POSIX systems (like Ubuntu or macOS), run the following command: ```sh -curl -fsSL https://bun.sh/install | bash +curl -fsSL https://bun.com/install | bash ``` -Otherwise, visit the [Bun installation page](https://bun.sh/docs/installation) for installation options. +Otherwise, visit the [Bun installation page](https://bun.com/docs/installation) for installation options. ## 💡 Commands @@ -87,7 +87,9 @@ We use [Biome](https://biomejs.dev/) for both linting and formatting with [a few #### IDE Setup -We recommend using [VS Code](https://code.visualstudio.com/) along with the [Biome extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome). +We recommend using [VS Code](https://code.visualstudio.com/) along with: +- [Biome extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for linting and formatting. +- [ArkType extension](https://marketplace.visualstudio.com/items?itemName=arktypeio.arkdark) for syntax highlighting and type-safe regex support. With the settings on the right, you can have auto fix and formatting when you save the code you are editing. diff --git a/README.md b/README.md index dc109b62..ae5d9b79 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Logo for Bedstack RealWorld example

Bedstack

-[![Tests Status](https://github.com/bedtime-coders/bedstack/actions/workflows/tests.yml/badge.svg?event=push&branch=main&)](https://github.com/bedtime-coders/bedstack/actions/workflows/tests.yml?query=branch%3Amain+event%3Apush) [![Discord](https://img.shields.io/discord/1164270344115335320?label=Chat&color=5865f4&logo=discord&labelColor=121214)](https://discord.gg/8UcP9QB5AV) [![License](https://custom-icon-badges.demolab.com/github/license/bedtime-coders/bedstack?label=License&color=blue&logo=law&labelColor=0d1117)](https://github.com/bedtime-coders/bedstack/blob/main/LICENSE) [![Bun](https://img.shields.io/badge/Bun-14151a?logo=bun&logoColor=fbf0df)](https://bun.sh/) [![ElysiaJS](https://custom-icon-badges.demolab.com/badge/ElysiaJS-0f172b.svg?logo=elysia)](https://elysiajs.com/) [![Drizzle](https://img.shields.io/badge/Drizzle-C5F74F?logo=drizzle&logoColor=000)](https://drizzle.team/) [![Biome](https://img.shields.io/badge/Biome-24272f?logo=biome&logoColor=f6f6f9)](https://biomejs.dev/) [![Scalar](https://img.shields.io/badge/Scalar-080808?logo=scalar&logoColor=e7e7e7)](https://scalar.com/) [![Star](https://custom-icon-badges.demolab.com/github/stars/bedtime-coders/bedstack?logo=star&logoColor=373737&label=Star)](https://github.com/bedtime-coders/bedstack/stargazers/) +[![Tests Status](https://github.com/bedtime-coders/bedstack/actions/workflows/tests.yml/badge.svg?event=push&branch=main&)](https://github.com/bedtime-coders/bedstack/actions/workflows/tests.yml?query=branch%3Amain+event%3Apush) [![Discord](https://img.shields.io/discord/1164270344115335320?label=Chat&color=5865f4&logo=discord&labelColor=121214)](https://discord.gg/8UcP9QB5AV) [![License](https://custom-icon-badges.demolab.com/github/license/bedtime-coders/bedstack?label=License&color=blue&logo=law&labelColor=0d1117)](https://github.com/bedtime-coders/bedstack/blob/main/LICENSE) [![Bun](https://img.shields.io/badge/Bun-14151a?logo=bun&logoColor=fbf0df)](https://bun.com/) [![ElysiaJS](https://custom-icon-badges.demolab.com/badge/ElysiaJS-0f172b.svg?logo=elysia)](https://elysiajs.com/) [![Drizzle](https://img.shields.io/badge/Drizzle-C5F74F?logo=drizzle&logoColor=000)](https://drizzle.team/) [![ArkType](https://custom-icon-badges.demolab.com/badge/ArkType-0d1526?logo=arktype2&logoColor=e9eef9)](https://arktype.io/) [![Biome](https://img.shields.io/badge/Biome-24272f?logo=biome&logoColor=f6f6f9)](https://biomejs.dev/) [![Scalar](https://img.shields.io/badge/Scalar-080808?logo=scalar&logoColor=e7e7e7)](https://scalar.com/) [![Star](https://custom-icon-badges.demolab.com/github/stars/bedtime-coders/bedstack?logo=star&logoColor=373737&label=Star)](https://github.com/bedtime-coders/bedstack/stargazers/) -[Bun](https://bun.sh/) + [ElysiaJS](https://elysiajs.com/) + [Drizzle](https://orm.drizzle.team/) = the stack you don't want to sleep on +[Bun](https://bun.com/) + [ElysiaJS](https://elysiajs.com/) + [Drizzle](https://orm.drizzle.team/) = the stack you don't want to sleep on [bedstack.js.org](https://bedstack.js.org) diff --git a/apps/conduit/README.md b/apps/conduit/README.md index 058defc9..d1b4e9b5 100644 --- a/apps/conduit/README.md +++ b/apps/conduit/README.md @@ -3,7 +3,7 @@ Logo for Bedstack RealWorld example

Conduit - Bedstack real world example

-[![Tests Status](https://github.com/bedtime-coders/bedstack/actions/workflows/tests.yml/badge.svg?event=push&branch=main&)](https://github.com/bedtime-coders/bedstack/actions/workflows/tests.yml?query=branch%3Amain+event%3Apush) [![Discord](https://img.shields.io/discord/1164270344115335320?label=Chat&color=5865f4&logo=discord&labelColor=121214)](https://discord.gg/8UcP9QB5AV) [![License](https://custom-icon-badges.demolab.com/github/license/bedtime-coders/bedstack?label=License&color=blue&logo=law&labelColor=0d1117)](https://github.com/bedtime-coders/bedstack/blob/main/LICENSE) [![Bun](https://img.shields.io/badge/Bun-14151a?logo=bun&logoColor=fbf0df)](https://bun.sh/) [![ElysiaJS](https://custom-icon-badges.demolab.com/badge/ElysiaJS-0f172b.svg?logo=elysia)](https://elysiajs.com/) [![Drizzle](https://img.shields.io/badge/Drizzle-C5F74F?logo=drizzle&logoColor=000)](https://orm.drizzle.team/) [![Biome](https://img.shields.io/badge/Biome-24272f?logo=biome&logoColor=f6f6f9)](https://biomejs.dev/) [![Scalar](https://img.shields.io/badge/Scalar-080808?logo=scalar&logoColor=e7e7e7)](https://scalar.com/) [![Star](https://custom-icon-badges.demolab.com/github/stars/bedtime-coders/bedstack?logo=star&logoColor=373737&label=Star)](https://github.com/bedtime-coders/bedstack/stargazers/) +[![Tests Status](https://github.com/bedtime-coders/bedstack/actions/workflows/tests.yml/badge.svg?event=push&branch=main&)](https://github.com/bedtime-coders/bedstack/actions/workflows/tests.yml?query=branch%3Amain+event%3Apush) [![Discord](https://img.shields.io/discord/1164270344115335320?label=Chat&color=5865f4&logo=discord&labelColor=121214)](https://discord.gg/8UcP9QB5AV) [![License](https://custom-icon-badges.demolab.com/github/license/bedtime-coders/bedstack?label=License&color=blue&logo=law&labelColor=0d1117)](https://github.com/bedtime-coders/bedstack/blob/main/LICENSE) [![Bun](https://img.shields.io/badge/Bun-14151a?logo=bun&logoColor=fbf0df)](https://bun.com/) [![ElysiaJS](https://custom-icon-badges.demolab.com/badge/ElysiaJS-0f172b.svg?logo=elysia)](https://elysiajs.com/) [![Drizzle](https://img.shields.io/badge/Drizzle-C5F74F?logo=drizzle&logoColor=000)](https://orm.drizzle.team/) [![Biome](https://img.shields.io/badge/Biome-24272f?logo=biome&logoColor=f6f6f9)](https://biomejs.dev/) [![Scalar](https://img.shields.io/badge/Scalar-080808?logo=scalar&logoColor=e7e7e7)](https://scalar.com/) [![Star](https://custom-icon-badges.demolab.com/github/stars/bedtime-coders/bedstack?logo=star&logoColor=373737&label=Star)](https://github.com/bedtime-coders/bedstack/stargazers/) [RealWorld](https://realworld-docs.netlify.app/) example app for [Bedstack](https://bedstack.js.org/) diff --git a/apps/www/getting-started.md b/apps/www/getting-started.md index d2eb69bb..a10e8a8e 100644 --- a/apps/www/getting-started.md +++ b/apps/www/getting-started.md @@ -8,7 +8,7 @@ Getting started with this RealWorld project is as easy as installing a few prere ### Prerequisites -- [Bun](https://bun.sh/) version 1.0.6 or higher. +- [Bun](https://bun.com/) version 1.0.6 or higher. - Terminal for accessing Bun via its command-line interface (CLI). - Text Editor with TypeScript support. - We recommend [Visual Studio Code](https://code.visualstudio.com/); other IDEs have been reported to cause issues with ElysiaJS's type inference system. diff --git a/apps/www/index.md b/apps/www/index.md index f3ccdf6d..6680d9bb 100644 --- a/apps/www/index.md +++ b/apps/www/index.md @@ -23,7 +23,7 @@ hero: features: - title: Bun details: All-in-one JavaScript runtime & toolkit designed for speed, complete with a bundler, test runner, and Node.js-compatible package manager. - link: https://bun.sh/ + link: https://bun.com/ icon: src: /bun-press-kit/logo-centered.svg @@ -38,6 +38,12 @@ features: link: https://orm.drizzle.team/ icon: src: /drizzle-logo.png + + - title: ArkType + details: The first and only runtime validator that can 1:1 match your TypeScript types. Fast, concise, and natively integrated for a "pit of success" experience. + link: https://arktype.io/ + icon: + src: /ark-logo.png ---