TypeScript Library
Overview¶
This example demonstrates a complete, production-ready TypeScript library that follows modern best practices for library development, testing, bundling, and publishing to NPM.
Library: ts-validator - A lightweight, type-safe validation library for TypeScript
Features: Schema validation, type guards, custom validators, chainable API, zero dependencies
Package Manager: pnpm (recommended for libraries)
Bundler: tsup (fast TypeScript bundler)
Testing: Vitest (fast, modern test runner)
Linting: ESLint + Prettier
This example showcases:
- ✅ Modern library structure with
src/layout - ✅ Dual ESM + CommonJS builds
- ✅ Type definitions (.d.ts) generation and export
- ✅ Generic types and type guards
- ✅ Comprehensive test coverage with Vitest
- ✅ ESLint + Prettier + TypeScript strict mode
- ✅ Bundling with tsup for optimal output
- ✅ NPM package configuration with proper exports
- ✅ GitHub Actions CI/CD pipeline
- ✅ Semantic versioning and changelog
- ✅ Documentation and usage examples
Project Structure¶
ts-validator/
├── src/
│ ├── validators/
│ │ ├── string.ts
│ │ ├── number.ts
│ │ ├── object.ts
│ │ ├── array.ts
│ │ └── index.ts
│ ├── types/
│ │ ├── validator.ts
│ │ ├── result.ts
│ │ └── index.ts
│ ├── utils/
│ │ ├── guards.ts
│ │ ├── errors.ts
│ │ └── index.ts
│ └── index.ts
├── tests/
│ ├── validators/
│ │ ├── string.test.ts
│ │ ├── number.test.ts
│ │ ├── object.test.ts
│ │ └── array.test.ts
│ └── integration.test.ts
├── dist/
│ ├── index.js (ESM)
│ ├── index.cjs (CommonJS)
│ ├── index.d.ts (Type definitions)
│ └── index.d.cts (CommonJS type definitions)
├── .github/
│ └── workflows/
│ └── ci.yml
├── package.json
├── tsconfig.json
├── tsconfig.build.json
├── tsup.config.ts
├── vitest.config.ts
├── .eslintrc.json
├── .prettierrc.json
├── .gitignore
├── LICENSE
├── README.md
└── CHANGELOG.md
Core Library Implementation¶
package.json¶
{
"name": "ts-validator",
"version": "1.0.0",
"description": "Lightweight, type-safe validation library for TypeScript",
"author": "Tyler Dukes <tyler@example.com>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tydukes/ts-validator.git"
},
"keywords": [
"typescript",
"validation",
"validator",
"schema",
"type-safe"
],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint src tests --ext .ts",
"lint:fix": "eslint src tests --ext .ts --fix",
"format": "prettier --write \"**/*.{ts,json,md}\"",
"type-check": "tsc --noEmit",
"prepublishOnly": "pnpm run lint && pnpm run test && pnpm run build",
"release": "pnpm run prepublishOnly && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.27.1",
"@types/node": "^20.10.0",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"@vitest/coverage-v8": "^1.0.4",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.1.1",
"tsup": "^8.0.1",
"typescript": "^5.3.3",
"vitest": "^1.0.4"
},
"engines": {
"node": ">=18.0.0"
},
"packageManager": "pnpm@8.12.0"
}
tsconfig.json¶
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
tsconfig.build.json¶
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["tests", "**/*.test.ts", "**/*.spec.ts"]
}
tsup.config.ts¶
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
minify: false,
treeshake: true,
outDir: 'dist',
});
vitest.config.ts¶
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/',
'dist/',
'tests/',
'**/*.test.ts',
'**/*.spec.ts',
'**/index.ts',
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
Type Definitions¶
src/types/result.ts¶
/**
* Validation result type
*/
export type ValidationResult<T> =
| { success: true; data: T }
| { success: false; errors: ValidationError[] };
/**
* Validation error details
*/
export interface ValidationError {
path: string[];
message: string;
code: string;
value?: unknown;
}
/**
* Validation context for error tracking
*/
export interface ValidationContext {
path: string[];
errors: ValidationError[];
}
/**
* Creates a new validation context
*/
export function createContext(path: string[] = []): ValidationContext {
return { path, errors: [] };
}
/**
* Adds an error to the context
*/
export function addError(
ctx: ValidationContext,
message: string,
code: string,
value?: unknown
): void {
ctx.errors.push({
path: [...ctx.path],
message,
code,
value,
});
}
src/types/validator.ts¶
import { ValidationResult } from './result';
/**
* Base validator interface
*/
export interface Validator<TInput = unknown, TOutput = TInput> {
/**
* Validates input and returns result
*/
validate(input: unknown): ValidationResult<TOutput>;
/**
* Parses input and throws on error
*/
parse(input: unknown): TOutput;
/**
* Checks if input is valid (type guard)
*/
is(input: unknown): input is TOutput;
/**
* Makes validator optional
*/
optional(): Validator<TInput | undefined, TOutput | undefined>;
/**
* Makes validator nullable
*/
nullable(): Validator<TInput | null, TOutput | null>;
/**
* Sets default value for undefined inputs
*/
default(value: TOutput): Validator<TInput, TOutput>;
}
/**
* Infers output type from validator
*/
export type Infer<T extends Validator<any, any>> = T extends Validator<any, infer Out>
? Out
: never;
Core Validators¶
src/validators/string.ts¶
import { Validator, ValidationResult, createContext, addError } from '../types';
export class StringValidator implements Validator<string, string> {
private minLength?: number;
private maxLength?: number;
private pattern?: RegExp;
private trimEnabled = false;
validate(input: unknown): ValidationResult<string> {
const ctx = createContext();
if (typeof input !== 'string') {
addError(ctx, 'Expected string', 'invalid_type', input);
return { success: false, errors: ctx.errors };
}
let value = input;
if (this.trimEnabled) {
value = value.trim();
}
if (this.minLength !== undefined && value.length < this.minLength) {
addError(
ctx,
`String must be at least ${this.minLength} characters`,
'too_short',
value
);
}
if (this.maxLength !== undefined && value.length > this.maxLength) {
addError(
ctx,
`String must be at most ${this.maxLength} characters`,
'too_long',
value
);
}
if (this.pattern && !this.pattern.test(value)) {
addError(ctx, 'String does not match pattern', 'invalid_pattern', value);
}
if (ctx.errors.length > 0) {
return { success: false, errors: ctx.errors };
}
return { success: true, data: value };
}
parse(input: unknown): string {
const result = this.validate(input);
if (!result.success) {
throw new Error(result.errors.map((e) => e.message).join(', '));
}
return result.data;
}
is(input: unknown): input is string {
return this.validate(input).success;
}
min(length: number): this {
this.minLength = length;
return this;
}
max(length: number): this {
this.maxLength = length;
return this;
}
length(length: number): this {
this.minLength = length;
this.maxLength = length;
return this;
}
regex(pattern: RegExp): this {
this.pattern = pattern;
return this;
}
email(): this {
this.pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return this;
}
url(): this {
this.pattern = /^https?:\/\/.+/;
return this;
}
trim(): this {
this.trimEnabled = true;
return this;
}
optional(): Validator<string | undefined, string | undefined> {
return new OptionalValidator(this);
}
nullable(): Validator<string | null, string | null> {
return new NullableValidator(this);
}
default(value: string): Validator<string, string> {
return new DefaultValidator(this, value);
}
}
export function string(): StringValidator {
return new StringValidator();
}
src/validators/number.ts¶
import { Validator, ValidationResult, createContext, addError } from '../types';
export class NumberValidator implements Validator<number, number> {
private minValue?: number;
private maxValue?: number;
private integerOnly = false;
private positiveOnly = false;
private nonNegativeOnly = false;
validate(input: unknown): ValidationResult<number> {
const ctx = createContext();
if (typeof input !== 'number' || Number.isNaN(input)) {
addError(ctx, 'Expected number', 'invalid_type', input);
return { success: false, errors: ctx.errors };
}
const value = input;
if (this.integerOnly && !Number.isInteger(value)) {
addError(ctx, 'Expected integer', 'not_integer', value);
}
if (this.positiveOnly && value <= 0) {
addError(ctx, 'Number must be positive', 'not_positive', value);
}
if (this.nonNegativeOnly && value < 0) {
addError(ctx, 'Number must be non-negative', 'negative', value);
}
if (this.minValue !== undefined && value < this.minValue) {
addError(ctx, `Number must be at least ${this.minValue}`, 'too_small', value);
}
if (this.maxValue !== undefined && value > this.maxValue) {
addError(ctx, `Number must be at most ${this.maxValue}`, 'too_large', value);
}
if (ctx.errors.length > 0) {
return { success: false, errors: ctx.errors };
}
return { success: true, data: value };
}
parse(input: unknown): number {
const result = this.validate(input);
if (!result.success) {
throw new Error(result.errors.map((e) => e.message).join(', '));
}
return result.data;
}
is(input: unknown): input is number {
return this.validate(input).success;
}
min(value: number): this {
this.minValue = value;
return this;
}
max(value: number): this {
this.maxValue = value;
return this;
}
int(): this {
this.integerOnly = true;
return this;
}
positive(): this {
this.positiveOnly = true;
return this;
}
nonnegative(): this {
this.nonNegativeOnly = true;
return this;
}
optional(): Validator<number | undefined, number | undefined> {
return new OptionalValidator(this);
}
nullable(): Validator<number | null, number | null> {
return new NullableValidator(this);
}
default(value: number): Validator<number, number> {
return new DefaultValidator(this, value);
}
}
export function number(): NumberValidator {
return new NumberValidator();
}
src/validators/object.ts¶
import { Validator, ValidationResult, createContext, addError } from '../types';
type Shape = Record<string, Validator<any, any>>;
type ObjectOutput<T extends Shape> = {
[K in keyof T]: T[K] extends Validator<any, infer Out> ? Out : never;
};
export class ObjectValidator<T extends Shape> implements Validator<unknown, ObjectOutput<T>> {
constructor(private shape: T) {}
validate(input: unknown): ValidationResult<ObjectOutput<T>> {
const ctx = createContext();
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
addError(ctx, 'Expected object', 'invalid_type', input);
return { success: false, errors: ctx.errors };
}
const result: any = {};
const obj = input as Record<string, unknown>;
for (const [key, validator] of Object.entries(this.shape)) {
const fieldResult = validator.validate(obj[key]);
if (!fieldResult.success) {
for (const error of fieldResult.errors) {
ctx.errors.push({
...error,
path: [key, ...error.path],
});
}
} else {
result[key] = fieldResult.data;
}
}
if (ctx.errors.length > 0) {
return { success: false, errors: ctx.errors };
}
return { success: true, data: result as ObjectOutput<T> };
}
parse(input: unknown): ObjectOutput<T> {
const result = this.validate(input);
if (!result.success) {
throw new Error(result.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '));
}
return result.data;
}
is(input: unknown): input is ObjectOutput<T> {
return this.validate(input).success;
}
optional(): Validator<unknown, ObjectOutput<T> | undefined> {
return new OptionalValidator(this);
}
nullable(): Validator<unknown, ObjectOutput<T> | null> {
return new NullableValidator(this);
}
default(value: ObjectOutput<T>): Validator<unknown, ObjectOutput<T>> {
return new DefaultValidator(this, value);
}
}
export function object<T extends Shape>(shape: T): ObjectValidator<T> {
return new ObjectValidator(shape);
}
src/validators/array.ts¶
import { Validator, ValidationResult, createContext, addError } from '../types';
type ArrayOutput<T> = T extends Validator<any, infer Out> ? Out[] : never;
export class ArrayValidator<T extends Validator<any, any>>
implements Validator<unknown, ArrayOutput<T>>
{
private minItems?: number;
private maxItems?: number;
private uniqueEnabled = false;
constructor(private itemValidator: T) {}
validate(input: unknown): ValidationResult<ArrayOutput<T>> {
const ctx = createContext();
if (!Array.isArray(input)) {
addError(ctx, 'Expected array', 'invalid_type', input);
return { success: false, errors: ctx.errors };
}
if (this.minItems !== undefined && input.length < this.minItems) {
addError(ctx, `Array must have at least ${this.minItems} items`, 'too_short', input);
}
if (this.maxItems !== undefined && input.length > this.maxItems) {
addError(ctx, `Array must have at most ${this.maxItems} items`, 'too_long', input);
}
const result: any[] = [];
const seen = new Set<string>();
for (let i = 0; i < input.length; i++) {
const itemResult = this.itemValidator.validate(input[i]);
if (!itemResult.success) {
for (const error of itemResult.errors) {
ctx.errors.push({
...error,
path: [String(i), ...error.path],
});
}
} else {
if (this.uniqueEnabled) {
const key = JSON.stringify(itemResult.data);
if (seen.has(key)) {
addError(ctx, 'Array items must be unique', 'duplicate_item', itemResult.data);
}
seen.add(key);
}
result.push(itemResult.data);
}
}
if (ctx.errors.length > 0) {
return { success: false, errors: ctx.errors };
}
return { success: true, data: result as ArrayOutput<T> };
}
parse(input: unknown): ArrayOutput<T> {
const result = this.validate(input);
if (!result.success) {
throw new Error(result.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '));
}
return result.data;
}
is(input: unknown): input is ArrayOutput<T> {
return this.validate(input).success;
}
min(length: number): this {
this.minItems = length;
return this;
}
max(length: number): this {
this.maxItems = length;
return this;
}
length(length: number): this {
this.minItems = length;
this.maxItems = length;
return this;
}
unique(): this {
this.uniqueEnabled = true;
return this;
}
optional(): Validator<unknown, ArrayOutput<T> | undefined> {
return new OptionalValidator(this);
}
nullable(): Validator<unknown, ArrayOutput<T> | null> {
return new NullableValidator(this);
}
default(value: ArrayOutput<T>): Validator<unknown, ArrayOutput<T>> {
return new DefaultValidator(this, value);
}
}
export function array<T extends Validator<any, any>>(itemValidator: T): ArrayValidator<T> {
return new ArrayValidator(itemValidator);
}
Modifier Validators¶
src/validators/modifiers.ts¶
import { Validator, ValidationResult, createContext, addError } from '../types';
export class OptionalValidator<T> implements Validator<T | undefined, T | undefined> {
constructor(private inner: Validator<any, T>) {}
validate(input: unknown): ValidationResult<T | undefined> {
if (input === undefined) {
return { success: true, data: undefined };
}
return this.inner.validate(input);
}
parse(input: unknown): T | undefined {
const result = this.validate(input);
if (!result.success) {
throw new Error(result.errors.map((e) => e.message).join(', '));
}
return result.data;
}
is(input: unknown): input is T | undefined {
return this.validate(input).success;
}
optional(): Validator<T | undefined, T | undefined> {
return this;
}
nullable(): Validator<T | undefined | null, T | undefined | null> {
return new NullableValidator(this);
}
default(value: T): Validator<T | undefined, T> {
return new DefaultValidator(this, value);
}
}
export class NullableValidator<T> implements Validator<T | null, T | null> {
constructor(private inner: Validator<any, T>) {}
validate(input: unknown): ValidationResult<T | null> {
if (input === null) {
return { success: true, data: null };
}
return this.inner.validate(input);
}
parse(input: unknown): T | null {
const result = this.validate(input);
if (!result.success) {
throw new Error(result.errors.map((e) => e.message).join(', '));
}
return result.data;
}
is(input: unknown): input is T | null {
return this.validate(input).success;
}
optional(): Validator<T | null | undefined, T | null | undefined> {
return new OptionalValidator(this);
}
nullable(): Validator<T | null, T | null> {
return this;
}
default(value: T): Validator<T | null, T> {
return new DefaultValidator(this, value);
}
}
export class DefaultValidator<T> implements Validator<T, T> {
constructor(
private inner: Validator<any, T | undefined>,
private defaultValue: T
) {}
validate(input: unknown): ValidationResult<T> {
const result = this.inner.validate(input);
if (!result.success) {
return result as ValidationResult<T>;
}
return { success: true, data: result.data ?? this.defaultValue };
}
parse(input: unknown): T {
const result = this.validate(input);
if (!result.success) {
throw new Error(result.errors.map((e) => e.message).join(', '));
}
return result.data;
}
is(input: unknown): input is T {
return this.validate(input).success;
}
optional(): Validator<T | undefined, T | undefined> {
return new OptionalValidator(this);
}
nullable(): Validator<T | null, T | null> {
return new NullableValidator(this);
}
default(value: T): Validator<T, T> {
return new DefaultValidator(this.inner, value);
}
}
Public API¶
src/index.ts¶
// Types
export type { Validator, Infer } from './types/validator';
export type { ValidationResult, ValidationError, ValidationContext } from './types/result';
// Validators
export { string } from './validators/string';
export { number } from './validators/number';
export { object } from './validators/object';
export { array } from './validators/array';
// Re-export commonly used types
export type { StringValidator } from './validators/string';
export type { NumberValidator } from './validators/number';
export type { ObjectValidator } from './validators/object';
export type { ArrayValidator } from './validators/array';
Testing¶
tests/validators/string.test.ts¶
import { describe, it, expect } from 'vitest';
import { string } from '../../src';
describe('StringValidator', () => {
describe('basic validation', () => {
it('should validate strings', () => {
const validator = string();
const result = validator.validate('hello');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('hello');
}
});
it('should reject non-strings', () => {
const validator = string();
const result = validator.validate(123);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.errors[0]?.code).toBe('invalid_type');
}
});
});
describe('min/max length', () => {
it('should validate min length', () => {
const validator = string().min(3);
expect(validator.validate('ab').success).toBe(false);
expect(validator.validate('abc').success).toBe(true);
expect(validator.validate('abcd').success).toBe(true);
});
it('should validate max length', () => {
const validator = string().max(5);
expect(validator.validate('abc').success).toBe(true);
expect(validator.validate('abcde').success).toBe(true);
expect(validator.validate('abcdef').success).toBe(false);
});
it('should validate exact length', () => {
const validator = string().length(5);
expect(validator.validate('abc').success).toBe(false);
expect(validator.validate('abcde').success).toBe(true);
expect(validator.validate('abcdef').success).toBe(false);
});
});
describe('regex patterns', () => {
it('should validate email', () => {
const validator = string().email();
expect(validator.validate('test@example.com').success).toBe(true);
expect(validator.validate('invalid-email').success).toBe(false);
});
it('should validate URL', () => {
const validator = string().url();
expect(validator.validate('https://example.com').success).toBe(true);
expect(validator.validate('not-a-url').success).toBe(false);
});
it('should validate custom regex', () => {
const validator = string().regex(/^[A-Z]+$/);
expect(validator.validate('ABC').success).toBe(true);
expect(validator.validate('abc').success).toBe(false);
});
});
describe('trim', () => {
it('should trim whitespace', () => {
const validator = string().trim();
const result = validator.validate(' hello ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('hello');
}
});
});
describe('optional/nullable', () => {
it('should accept undefined when optional', () => {
const validator = string().optional();
expect(validator.validate(undefined).success).toBe(true);
expect(validator.validate('hello').success).toBe(true);
});
it('should accept null when nullable', () => {
const validator = string().nullable();
expect(validator.validate(null).success).toBe(true);
expect(validator.validate('hello').success).toBe(true);
});
});
describe('default values', () => {
it('should use default for undefined', () => {
const validator = string().optional().default('default');
const result = validator.validate(undefined);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('default');
}
});
});
describe('type guards', () => {
it('should work as type guard', () => {
const validator = string().email();
const input: unknown = 'test@example.com';
if (validator.is(input)) {
// TypeScript knows input is string here
expect(input.toLowerCase()).toBe('test@example.com');
}
});
});
describe('parse method', () => {
it('should return value on success', () => {
const validator = string();
expect(validator.parse('hello')).toBe('hello');
});
it('should throw on error', () => {
const validator = string();
expect(() => validator.parse(123)).toThrow();
});
});
});
tests/validators/object.test.ts¶
import { describe, it, expect } from 'vitest';
import { object, string, number } from '../../src';
describe('ObjectValidator', () => {
it('should validate object shape', () => {
const validator = object({
name: string(),
age: number().int().nonnegative(),
});
const result = validator.validate({
name: 'John',
age: 30,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('John');
expect(result.data.age).toBe(30);
}
});
it('should reject invalid object', () => {
const validator = object({
name: string(),
age: number(),
});
const result = validator.validate({
name: 'John',
age: 'not a number',
});
expect(result.success).toBe(false);
});
it('should include field path in errors', () => {
const validator = object({
user: object({
name: string().min(3),
}),
});
const result = validator.validate({
user: { name: 'ab' },
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.errors[0]?.path).toEqual(['user', 'name']);
}
});
it('should infer types correctly', () => {
const validator = object({
name: string(),
age: number(),
email: string().email().optional(),
});
type User = (typeof validator) extends { parse: (input: unknown) => infer T } ? T : never;
const user: User = {
name: 'John',
age: 30,
email: 'john@example.com',
};
expect(validator.parse(user)).toEqual(user);
});
});
tests/integration.test.ts¶
import { describe, it, expect } from 'vitest';
import { object, string, number, array, type Infer } from '../src';
describe('Integration', () => {
it('should validate complex nested schema', () => {
const addressSchema = object({
street: string(),
city: string(),
zipCode: string().regex(/^\d{5}$/),
});
const userSchema = object({
id: string(),
name: string().min(2).max(50),
email: string().email(),
age: number().int().min(18).max(120),
address: addressSchema,
tags: array(string()).min(1).max(5),
});
type User = Infer<typeof userSchema>;
const validUser: unknown = {
id: '123',
name: 'John Doe',
email: 'john@example.com',
age: 30,
address: {
street: '123 Main St',
city: 'New York',
zipCode: '10001',
},
tags: ['developer', 'typescript'],
};
const result = userSchema.validate(validUser);
expect(result.success).toBe(true);
if (result.success) {
const user: User = result.data;
expect(user.name).toBe('John Doe');
expect(user.address.city).toBe('New York');
}
});
it('should validate API response schema', () => {
const apiResponseSchema = object({
data: array(
object({
id: number(),
title: string(),
completed: boolean(),
})
),
pagination: object({
page: number().int().positive(),
pageSize: number().int().positive(),
total: number().int().nonnegative(),
}),
});
const response: unknown = {
data: [
{ id: 1, title: 'Task 1', completed: false },
{ id: 2, title: 'Task 2', completed: true },
],
pagination: {
page: 1,
pageSize: 10,
total: 2,
},
};
const result = apiResponseSchema.validate(response);
expect(result.success).toBe(true);
});
});
Linting and Formatting¶
.eslintrc.json¶
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/no-floating-promises": "error"
},
"ignorePatterns": ["dist", "node_modules", "*.config.ts"]
}
.prettierrc.json¶
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "always"
}
CI/CD Pipeline¶
.github/workflows/ci.yml¶
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check
run: pnpm type-check
- name: Lint
run: pnpm lint
- name: Run tests
run: pnpm test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
flags: unittests
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Check bundle size
run: |
ls -lh dist/
du -sh dist/
publish:
needs: [test, build]
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Publish to NPM
run: pnpm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Documentation¶
README.md¶
## ts-validator
Lightweight, type-safe validation library for TypeScript with zero dependencies.
## Features
- ✅ Type-safe validation with full TypeScript support
- ✅ Chainable API for building complex validators
- ✅ Zero runtime dependencies
- ✅ Tree-shakeable (ESM + CommonJS)
- ✅ Comprehensive error messages
- ✅ Type guards for narrowing
- ✅ Support for optional, nullable, and default values
## Installation
```bash
npm install ts-validator
## or
yarn add ts-validator
## or
pnpm add ts-validator
Quick Start¶
import { object, string, number, array } from 'ts-validator';
// Define a schema
const userSchema = object({
name: string().min(2).max(50),
email: string().email(),
age: number().int().min(18),
tags: array(string()).optional(),
});
// Validate data
const result = userSchema.validate({
name: 'John Doe',
email: 'john@example.com',
age: 30,
});
if (result.success) {
console.log('Valid user:', result.data);
} else {
console.error('Validation errors:', result.errors);
}
// Or use parse (throws on error)
const user = userSchema.parse(data);
// Type inference
type User = typeof userSchema extends { parse: (input: unknown) => infer T } ? T : never;
API Reference¶
String Validators¶
string() // Basic string validation
.min(3) // Minimum length
.max(50) // Maximum length
.length(10) // Exact length
.email() // Email validation
.url() // URL validation
.regex(/pattern/) // Custom regex
.trim() // Trim whitespace
.optional() // Allow undefined
.nullable() // Allow null
.default('value') // Default value
Number Validators¶
number() // Basic number validation
.min(0) // Minimum value
.max(100) // Maximum value
.int() // Integer only
.positive() // Positive numbers only
.nonnegative() // Non-negative numbers
.optional() // Allow undefined
.nullable() // Allow null
.default(0) // Default value
Object Validators¶
object({ // Object shape validation
name: string(),
age: number(),
})
.optional() // Allow undefined
.nullable() // Allow null
Array Validators¶
array(string()) // Array of strings
.min(1) // Minimum items
.max(10) // Maximum items
.length(5) // Exact length
.unique() // Unique items only
.optional() // Allow undefined
.nullable() // Allow null
License¶
MIT © Tyler Dukes
Key Takeaways¶
This TypeScript library example demonstrates:
- Modern Package Configuration: Dual ESM/CommonJS builds with proper exports
- Type Safety: Full TypeScript support with generics and type inference
- Developer Experience: Chainable API, type guards, and helpful error messages
- Testing: Comprehensive test coverage with Vitest
- Build Pipeline: Optimized bundling with tsup
- CI/CD: Automated testing, linting, and publishing with GitHub Actions
- Documentation: Clear README with examples and API reference
- Maintainability: ESLint + Prettier + strict TypeScript configuration
References¶
- TypeScript Handbook
- tsup Documentation
- Vitest Documentation
- pnpm Documentation
- NPM Package Best Practices
Status: Active