A tiny schema builder that gives you runtime validation and TypeScript type inference at the same time — with practical DTO ⇄ Domain mapping examples for clean architecture.
Validate everything at the boundary and pass clean, typed data into your domain.
- Runtime validation + types from one schema declaration (
Infer
for types). - Helpful errors: each issue has
path / code / message
. - Sync & async validation (
parse
/safeParse
,parseAsync
/safeParseAsync
). - Clean-architecture friendly: schemas at boundaries, a pure domain layer.
- Small, composable API:
s.string()
,s.number()
,s.object()
, etc., plus.optional()
,.nullable()
,.transform()
,.refine()
,.default()
.
- Node.js (LTS recommended)
- TypeScript (if you want typing)
tsx
orts-node
to run examples
pnpm i # or npm i / yarn
npx tsx ./src/demo/simple/user-mapper.smoke.ts
import { s, Infer } from '@/schema';
// 1) Declare a schema
const UserSchema = s
.object({
id: s.number().int().min(1),
name: s.string().min(1),
email: s.string().email().nullable().optional(), // allows null or missing
})
.strict(); // reject unknown keys
// 2) Get a TypeScript type from the schema
type User = Infer<typeof UserSchema>;
// -> { id: number; name: string; email?: string | null | undefined }
// 3) Validate data at runtime (no exceptions with safeParse)
const r = UserSchema.safeParse(input);
if (r.success) {
const user: User = r.data;
} else {
console.log(r.error.issues); // [{ path, code, message }, ...]
}
- Schema classes extend
BaseSchema<T>
and implement_parse(value, path)
.- If the value is valid → return the typed value (
T
). - If not → throw a
ValidationError
with helpfulissues
.
- If the value is valid → return the typed value (
- Builder
s
creates schemas:- Primitives:
s.string()
,s.number()
,s.boolean()
,s.enum([...])
- Containers:
s.array(inner)
,s.object(shape)
,s.union([a, b])
- Modifiers:
.optional()
(allowundefined
),.nullable()
(allownull
),.transform(fn)
,.refine(predicate)
,.default(v)
- Primitives:
- Error model (
ValidationError
):
issues: Array<{ path: (string|number)[], code: string, message: string }>
- Common codes:
invalid_type
,too_small
,too_big
,invalid_string
,invalid_enum
,invalid_array
,invalid_object
,unrecognized_keys
,custom
- Common codes:
// Strings
s.string().min(1).max(30).regex(/[a-z]/i).email();
// Numbers
s.number().int().min(1).max(100);
// Boolean
s.boolean();
// Enums
s.enum(['admin', 'user', 'guest'] as const);
// Arrays
s.array(s.string().min(1));
// Objects
s.object({ id: s.number(), name: s.string() }).strict();
// Unions
s.union([s.string(), s.number()]);
// Presence & nullability
s.string().nullable().optional();
// (Usually prefer .nullable().optional() when both are allowed)
-
parse(value)
Returns the value or throwsValidationError
.
If an async check is inside, throwsAsyncParseError
→ useparseAsync
. -
safeParse(value)
Returns{ success: true, data }
or{ success: false, error }
.
No exceptions → great for controllers/handlers. -
Async versions:
parseAsync(value)
,safeParseAsync(value)
.
DTO schema (Data layer)
export type UserDTO = {
ID: number | null;
USER_NAME: string | null;
EMAIL?: string | null;
ROLE: 'admin' | 'user' | 'guest';
CREATED_AT: string | null; // ISO
};
export const UserDTOSchema = s
.object({
ID: s.number().int().min(1).nullable(),
USER_NAME: s.string().min(1).nullable(),
EMAIL: s.string().email().nullable().optional(),
ROLE: s.enum(['admin', 'user', 'guest'] as const),
CREATED_AT: s.string().min(1).nullable(),
})
.strict();
Domain (pure types)
export enum UserRole {
Admin = 'Admin',
User = 'User',
Guest = 'Guest',
}
export interface User {
id: number;
name: string;
email?: string; // domain drops null by policy (optional)
role: UserRole;
createdAt: Date;
}
Mapper (short version)
import { ok, err, type Result, ValidationError } from '@/schema';
type R<T> = Result<T, ValidationError>;
export class UserMapper {
toDomain(raw: unknown): R<User> {
const parsed = UserDTOSchema.safeParse(raw);
if (!parsed.success) return err(parsed.error);
const dto = parsed.data;
const t = Date.parse(dto.CREATED_AT as string);
if (Number.isNaN(t)) {
return err(
new ValidationError([{ path: ['CREATED_AT'], code: 'custom', message: 'Invalid ISO date' }])
);
}
const role = this.roleDtoToDomain(dto.ROLE);
if (!role.ok) return role as R<User>;
return ok({
id: dto.ID!,
name: dto.USER_NAME!,
email: dto.EMAIL ?? undefined, // normalize null → undefined
role: role.value,
createdAt: new Date(t),
});
}
// ...toDTO(...) similar: enum to string, Date → ISO (with error handling)
}
npx tsx ./src/demo/simple/user-mapper.smoke.ts
Example output (excerpt)
[toDomain / OK #1] ✅ OK
{ id: 1, name: 'Lux', email: undefined, role: 'Admin', createdAt: 2025-08-31T12:00:00.000Z }
[toDomain / FAIL bad ROLE] ❌ ERR
{ message: 'Validation error',
issues: [{ path: ['ROLE'], code: 'invalid_enum', message: 'Expected one of [admin, user, guest]' }] }
[toDTO / FAIL invalid Date] ❌ ERR
{ message: 'Validation error',
issues: [{ path: ['createdAt'], code: 'custom', message: 'RangeError: Invalid time value' }] }
src/
schema/ # schema builder + docs
demo/ # DTO ⇄ Domain examples and smoke tests
LICENSE
- For deeper docs, see
src/schema/README.md
- null vs undefined?
Many teams accept both at the boundary and normalize toundefined
in Domain. - Order of
.nullable()
and.optional()
?
Prefer.nullable().optional()
for “null or missing or valid”. - Type error about BaseSchema mismatch?
You likely imported two differentBaseSchema
s (barrel vs source,src
vsdist
). Unify imports.