bookish-potato-dto
Version:
A TypeScript DTO (Data Transfer Object) parsing and validation library. Define a schema once — get runtime validation and a fully inferred TypeScript type for free.
343 lines (261 loc) • 9.28 kB
Markdown
# bookish-potato-dto
A TypeScript DTO (Data Transfer Object) parsing and validation library.
Define a schema once — get runtime validation and a fully inferred TypeScript type for free.
## Table of Contents
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Field builders](#field-builders)
- [All field options](#all-field-options)
- [Composing and extending DTOs](#composing-and-extending-dtos)
- [Nested DTOs](#nested-dtos)
- [Enum fields](#enum-fields)
- [Custom parsers](#custom-parsers)
- [OpenAPI Schema Generation](#openapi-schema-generation)
- [Type inference with InferDto](#type-inference-with-inferdto)
- [Use cases](#use-cases)
- [REST API body parsing](#rest-api-body-parsing)
- [Environment / config parsing](#environment--config-parsing)
- [Data transformation](#data-transformation)
- [Feature Requests & Bug Reports](#feature-requests-bugs-reports-and-contributions)
## Installation
```bash
npm install bookish-potato-dto
```
No `tsconfig.json` flags required. Works in Node.js, Bun, Deno, and any ESM environment.
## Quick Start
```typescript
import { defineDto, field, InferDto, parseObject } from 'bookish-potato-dto';
const PersonDto = defineDto({
name: field.string(),
age: field.integer({ strictDataTypes: true }),
height: field.number(),
weight: field.number({ defaultValue: 70 }),
eyeColor: field.string({ isOptional: true }),
active: field.boolean(),
});
// Derive the TypeScript type — no duplication
type PersonDto = InferDto<typeof PersonDto>;
const person = parseObject(PersonDto, {
name: 'John Doe',
age: 30,
height: 180.5,
active: true,
});
// person.name === 'John Doe'
// person.weight === 70 (defaultValue applied)
// person.eyeColor === undefined (optional, not provided)
```
## Field builders
| Builder | Description |
|---|---|
| `field.string(opts?)` | String field |
| `field.number(opts?)` | Floating-point number |
| `field.integer(opts?)` | Integer |
| `field.boolean(opts?)` | Boolean |
| `field.enum(E, opts?)` | Enum value |
| `field.date(opts?)` | Date instance or ISO string |
| `field.regex(re, opts?)` | String validated against a regex |
| `field.array(type, opts?)` | Array of `'string'`, `'number'`, or `'boolean'` |
| `field.arrayDto(Dto, opts?)` | Array of nested DTOs |
| `field.dto(Dto, opts?)` | Single nested DTO |
| `field.custom(opts)` | Custom parser instance |
## All field options
### Common options (all fields)
| Option | Type | Description |
|---|---|---|
| `isOptional` | `boolean` | Field may be absent. TypeScript type becomes `T \| undefined`. |
| `isNullable` | `boolean` | Field may be `null`. TypeScript type becomes `T \| null`. |
| `defaultValue` | `T \| null` | Fallback value when field is absent. |
| `useDefaultValueOnParseError` | `boolean` | Use `defaultValue` instead of throwing on bad input. |
| `mapFrom` | `string` | Read from a differently-named key in the raw input. |
| `parsingErrorMessage` | `(key, value, error) => string` | Custom error message function. |
| `openApi` | `OpenApiSchema` | Manual overrides or additional metadata for OpenAPI generation. |
### String options (`field.string`)
| Option | Type | Description |
|---|---|---|
| `minLength` | `number` | Minimum string length. |
| `maxLength` | `number` | Maximum string length. |
### Number and integer options (`field.number`, `field.integer`)
| Option | Type | Description |
|---|---|---|
| `strictDataTypes` | `boolean` | Disable string→number coercion. |
| `minValue` | `number` | Minimum value. |
| `maxValue` | `number` | Maximum value. |
### Boolean options (`field.boolean`)
| Option | Type | Description |
|---|---|---|
| `strictDataTypes` | `boolean` | Disable `"true"`/`"false"` string coercion. |
### Array options (`field.array`)
| Option | Type | Description |
|---|---|---|
| `minLength` | `number` | Minimum array length. |
| `maxLength` | `number` | Maximum array length. |
| `strictDataTypes` | `boolean` | Disable coercion for items. |
| `stringsLength` | `{ minLength?, maxLength? }` | Per-item length constraint (string arrays). |
| `numbersRange` | `{ minValue?, maxValue? }` | Per-item range constraint (number arrays). |
### Array of DTO options (`field.arrayDto`)
| Option | Type | Description |
|---|---|---|
| `minLength` | `number` | Minimum array length. |
| `maxLength` | `number` | Maximum array length. |
## Composing and extending DTOs
Schemas are plain objects. Use the spread operator to compose them:
```typescript
const PersonDto = defineDto({
name: field.string(),
age: field.integer(),
});
// Extend with new fields
const EmployeeDto = defineDto({
...PersonDto.fields,
position: field.string(),
});
// Three-way composition
const TimestampedDto = defineDto({ createdAt: field.date() });
const AuditedEmployeeDto = defineDto({
...EmployeeDto.fields,
...TimestampedDto.fields,
});
```
## Nested DTOs
```typescript
const AddressDto = defineDto({
street: field.string(),
city: field.string(),
});
const PersonDto = defineDto({
name: field.string(),
address: field.dto(AddressDto), // single nested DTO
addresses: field.arrayDto(AddressDto), // array of nested DTOs
});
```
## Enum fields
```typescript
enum Role { Admin = 'admin', User = 'user', Guest = 'guest' }
const UserDto = defineDto({
name: field.string(),
role: field.enum(Role),
});
```
## Custom parsers
```typescript
class CsvParser {
parse(value: unknown): string[] {
if (typeof value !== 'string') throw new Error('not a string');
return value.split(',').filter(Boolean);
}
}
const ConfigDto = defineDto({
port: field.integer({ mapFrom: 'PORT', defaultValue: 3000 }),
origins: field.custom({ mapFrom: 'ALLOWED_ORIGINS', parser: new CsvParser() }),
});
```
## OpenAPI Schema Generation
`bookish-potato-dto` can automatically generate OpenAPI 3.0/3.1 compatible schemas from your DTO definitions. It infers types, constraints (like `minLength`, `minimum`), nullability, and default values.
```typescript
import { defineDto, field, generateOpenApi } from 'bookish-potato-dto';
const UserDto = defineDto({
id: field.string({ openApi: { format: 'uuid' } }),
email: field.string({ openApi: { format: 'email', description: 'User contact email' } }),
age: field.integer({ minValue: 18 }),
});
const { schema, refs } = generateOpenApi(UserDto, {
// Optional: provide meaningful names for $ref resolution
nameResolver: (dto) => dto === UserDto ? 'User' : dto._uuid
});
// schema: { $ref: '#/components/schemas/User' }
// refs.User: {
// type: 'object',
// properties: {
// id: { type: 'string', format: 'uuid' },
// email: { type: 'string', format: 'email', description: 'User contact email' },
// age: { type: 'integer', minimum: 18 }
// },
// required: ['id', 'email', 'age']
// }
```
### Manual Overrides
Use the `openApi` option on any field to add descriptions, examples, or override the automatically inferred schema.
```typescript
field.string({
minLength: 5,
openApi: {
description: 'A unique username',
example: 'john_doe',
maxLength: 20
}
})
```
## Type inference with InferDto
`InferDto<T>` produces the full TypeScript type from a `DtoDefinition`. Optional fields
(`isOptional: true`) become `T | undefined`. Nullable fields (`isNullable: true`) become `T | null`.
```typescript
const PersonDto = defineDto({
name: field.string(),
age: field.integer(),
nickname: field.string({ isOptional: true }),
score: field.number({ isNullable: true }),
});
type PersonDto = InferDto<typeof PersonDto>;
// Equivalent to:
// {
// name: string;
// age: number;
// nickname?: string;
// score: number | null;
// }
```
## Use cases
### REST API body parsing
```typescript
enum Roles { Admin = 'admin', User = 'user' }
const RoleDto = defineDto({
name: field.string(),
role: field.enum(Roles),
});
const UserDto = defineDto({
name: field.string(),
email: field.string(),
age: field.integer(),
role: field.dto(RoleDto),
});
// In your request handler:
const user = parseObject(UserDto, req.body);
```
### Environment / config parsing
```typescript
enum LogLevel { Info = 'info', Debug = 'debug', Error = 'error' }
const ConfigDto = defineDto({
port: field.integer({ mapFrom: 'PORT', defaultValue: 3000 }),
logLevel: field.enum(LogLevel, { mapFrom: 'LOG_LEVEL', defaultValue: LogLevel.Info }),
dbSecret: field.string({ mapFrom: 'DB_SECRET' }),
allowedOrigins: field.custom({ mapFrom: 'ALLOWED_ORIGINS', parser: new CsvParser() }),
});
export const config = parseObject(ConfigDto, process.env);
```
### Data transformation
```typescript
const PersonDto = defineDto({
id: field.string({ mapFrom: 'uuid' }),
name: field.string(),
status: field.string({ defaultValue: 'active' }),
});
const person = parseObject(PersonDto, { uuid: '123', name: 'Alice' });
// person.id === '123'
// person.status === 'active'
```
## Feature Requests, Bugs Reports, and Contributions
Please use the [GitHub Issues](https://github.com/andrei-trukhin/bookish-potato-dto-issues)
repository to report bugs, request features, or ask questions.