Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ Thumbs.db
npm-debug.log*


.omc/
.omc/
plans/
test-report.junit.xml
41 changes: 39 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# tsentials — Developer Guide

Railway-oriented programming toolkit for TypeScript.
Modules: `result`, `maybe`, `errors`, `rules`, `entity`, `http`, `time`, `clone`, `union`, `json`.
Modules: `result`, `maybe`, `errors`, `rules`, `entity`, `http`, `time`, `clone`, `union`, `json`, `string`.

## Commands

Expand Down Expand Up @@ -31,6 +31,7 @@ src/
clone/ — Cloneable<T>, deepClone(), cloneArray()
union/ — Union<T> discriminated union utility
json/ — Json types, isJson/isJsonObject guards, safeJsonParse(), safeJsonStringify(), parseAndValidate()
string/ — String case conversion (toPascalCase, toCamelCase, toKebabCase, toSnakeCase, toMacroCase, toTrainCase, toTitleCase, toUnderscoreCamelCase)
```

### Result<T>
Expand Down Expand Up @@ -84,6 +85,8 @@ Result.or([r1, r2]) // first success, else all errors
Result.combine(r1, r2, r3) // heterogeneous → Result<[T1, T2, T3]>
Result.flatten(Result.success(r)) // Result<Result<T>> → Result<T>
Result.always(r, fn) // unconditional — returns fn result
Result.traverse(items, fn) // A[] → (A → Result<B>) → Result<B[]>, collects ALL errors
await Result.traverseAsync(items, async fn) // async version

// Fluent chain — bind() NOT then()
chain(Result.success(5)).bind(fn).map(fn).ensure(pred, err).match(ok, err)
Expand Down Expand Up @@ -238,6 +241,20 @@ await RequestBuilder.post('/users').json({ name: 'Alice' }).send<User>();

// Status → ErrorType: 400/422→Validation, 401→Unauthorized, 403→Forbidden,
// 404/410→NotFound, 409/429→Conflict, ≥500→Unexpected

import { HttpCodes } from 'tsentials/http';
import type { HttpCode } from 'tsentials/http';

// Type-safe HTTP status constants
HttpCodes.Ok // 200
HttpCodes.Created // 201
HttpCodes.NoContent // 204
HttpCodes.BadRequest // 400
HttpCodes.Unauthorized // 401
HttpCodes.Forbidden // 403
HttpCodes.NotFound // 404
HttpCodes.Conflict // 409
// ... 21 total constants
```

### Union\<T\>
Expand All @@ -251,6 +268,10 @@ const s: Shape = { tag: 'circle', value: { radius: 5 } };
Union.match(s, { circle: ({ radius }) => radius * 2, rect: ({ w, h }) => w * h });
Union.is(s, 'circle') // type guard
Union.get(s, 'circle') // value or throws

// Collection utilities
Union.partition(items, 'leftTag', 'rightTag') // → { lefts: Left[], rights: Right[] }
Union.groupBy(items) // → { [tag]: value[] }
```

### Json
Expand Down Expand Up @@ -287,13 +308,29 @@ isJson({ fn: () => {} }) // false — functions not valid JSON
const processed = Result.then(safeJsonParse(rawInput), data => validatePayload(data));
```

### String

```typescript
import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase, toMacroCase, toTrainCase, toTitleCase, toUnderscoreCamelCase } from 'tsentials/string';

// Case conversion utilities
toPascalCase('hello-world') // "HelloWorld"
toCamelCase('hello-world') // "helloWorld"
toKebabCase('helloWorld') // "hello-world"
toSnakeCase('helloWorld') // "hello_world"
toMacroCase('helloWorld') // "HELLO_WORLD"
toTrainCase('hello_world') // "Hello-World"
toTitleCase('helloWorld') // "Hello World"
toUnderscoreCamelCase('helloWorld') // "_helloWorld"
```

## TypeScript configuration
- `strict: true`, `exactOptionalPropertyTypes: true`, `noUncheckedIndexedAccess: true`
- ESM only (`"type": "module"`), `moduleResolution: "bundler"`
- `"sideEffects": false` in package.json for full tree-shaking

## Testing
Vitest — `npm test` runs all 652 tests across 22 test files.
Vitest — `npm test` runs all 1075 tests across 33 test files.
Test files mirror src/ structure under `tests/`.

## Publishing
Expand Down
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
[![npm version](https://img.shields.io/npm/v/tsentials?style=flat-square&color=blue)](https://www.npmjs.com/package/tsentials)
[![npm downloads](https://img.shields.io/npm/dm/tsentials?style=flat-square)](https://www.npmjs.com/package/tsentials)
[![bundle size](https://img.shields.io/bundlephobia/minzip/tsentials?style=flat-square&label=gzip)](https://bundlephobia.com/package/tsentials)
[![tests](https://img.shields.io/badge/tests-978%20passing-brightgreen?style=flat-square)](./tests)
[![tests](https://img.shields.io/badge/tests-1079%20passing-brightgreen?style=flat-square)](./tests)
[![CI](https://img.shields.io/github/actions/workflow/status/senrecep/tsentials/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/senrecep/tsentials/actions)
[![license](https://img.shields.io/github/license/senrecep/tsentials?style=flat-square)](./LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0%2B-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
Expand Down Expand Up @@ -58,6 +58,7 @@ Railway-oriented programming for TypeScript — `Result<T>`, `Maybe<T>`, Rule En
- [These\<E, A\>](#these-e-a)
- [Tree\<T\>](#treet)
- [Record Utilities](#record-utilities)
- [String Utilities](#string-utilities)
- [Design Notes](#design-notes)
- [AI Skills](#ai-skills)

Expand Down Expand Up @@ -91,6 +92,7 @@ npm install tsentials
| `tsentials/these` | `These<E, A>`, `toResult`, `fromResult`, `partition` |
| `tsentials/tree` | `Tree<T>`, `map`, `filter`, `fold`, `drawTree` |
| `tsentials/record` | `Record` utilities — `map`, `filter`, `pick`, `omit`, `reduce` |
| `tsentials/string` | String case conversion utilities (Pascal, Camel, Kebab, Snake, Macro, Train, Title, _camelCase) |

---

Expand Down Expand Up @@ -279,6 +281,12 @@ Result.always(result, r => {
console.log(r.ok ? 'success' : 'failure');
return 'done';
});

// traverse: map array items through a Result-returning fn, collect ALL errors
Result.traverse([1, 2, 3], n => n > 0 ? Result.success(n * 2) : Result.failure(err));
// → Result<number[]>
await Result.traverseAsync([1, 2], async n => fetchUser(n));
// → Promise<Result<User[]>>
```

---
Expand Down Expand Up @@ -556,6 +564,16 @@ Status code mapping:

Supports `application/problem+json` (RFC 9457) for error descriptions.

```typescript
import { HttpCodes } from 'tsentials/http';
import type { HttpCode } from 'tsentials/http';

// Type-safe HTTP status constants (no magic numbers)
const status: HttpCode = HttpCodes.Ok; // 200
const notFound = HttpCodes.NotFound; // 404
const serverErr = HttpCodes.InternalServerError; // 500
```

---

## Union\<T\>
Expand Down Expand Up @@ -586,6 +604,14 @@ if (Union.is(result, 'success')) {

// Unsafe extraction
const id = Union.get(result, 'success').transactionId; // throws if wrong tag

// partition: split union array into two typed arrays by tag
const { lefts, rights } = Union.partition(shapes, 'circle', 'rect');
// lefts: Array<{ radius: number }>, rights: Array<{ w: number; h: number }>

// groupBy: group all items by tag into a record
const groups = Union.groupBy(shapes);
// { circle: [...], rect: [...] }
```

---
Expand Down Expand Up @@ -837,6 +863,23 @@ R.pick(users, 'a'); // { a: { name: 'Alice' } }
R.omit(users, 'b'); // { a: { name: 'Alice' } }
```

## String Utilities

```typescript
import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase, toMacroCase, toTrainCase, toTitleCase, toUnderscoreCamelCase } from 'tsentials/string';

toPascalCase('hello world') // 'HelloWorld'
toCamelCase('hello-world') // 'helloWorld'
toKebabCase('HelloWorld') // 'hello-world'
toSnakeCase('helloWorld') // 'hello_world'
toMacroCase('hello world') // 'HELLO_WORLD' (SCREAMING_SNAKE_CASE)
toTrainCase('hello world') // 'Hello-World'
toTitleCase('hello world foo') // 'Hello World Foo'
toUnderscoreCamelCase('helloWorld') // '_helloWorld'
```

All functions handle: spaces, hyphens, underscores, camelCase, PascalCase, and mixed input.

## Design Notes

- **`Result<T>`** — discriminated union, no class, zero runtime overhead
Expand Down
2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ <h1><span>tsentials</span></h1>
<img src="https://img.shields.io/npm/v/tsentials?style=flat-square&color=blue" alt="npm version" />
<img src="https://img.shields.io/npm/dm/tsentials?style=flat-square" alt="npm downloads" />
<img src="https://img.shields.io/bundlephobia/minzip/tsentials?style=flat-square&label=gzip" alt="bundle size" />
<img src="https://img.shields.io/badge/tests-978%20passing-brightgreen?style=flat-square" alt="tests" />
<img src="https://img.shields.io/badge/tests-1079%20passing-brightgreen?style=flat-square" alt="tests" />
<img src="https://img.shields.io/github/actions/workflow/status/senrecep/tsentials/ci.yml?branch=main&style=flat-square&label=CI" alt="CI" />
<img src="https://img.shields.io/badge/TypeScript-5.0%2B-blue?style=flat-square&logo=typescript" alt="TypeScript" />
<img src="https://img.shields.io/badge/node-%3E%3D18-339933?style=flat-square&logo=node.js" alt="Node.js" />
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@
"./record": {
"import": "./dist/record/index.js",
"types": "./dist/record/index.d.ts"
},
"./string": {
"import": "./dist/string/index.js",
"types": "./dist/string/index.d.ts"
}
},
"sideEffects": false,
Expand Down
3 changes: 0 additions & 3 deletions src/clone/cloneable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ function cloneArrayBuffer(buffer: ArrayBufferLike): ArrayBufferLike {
}

function cloneTypedArray(view: ArrayBufferView, buffer: ArrayBufferLike): ArrayBufferView {
if (view instanceof DataView) {
return new DataView(buffer, view.byteOffset, view.byteLength);
}
const Constructor = view.constructor as new (
buffer: ArrayBufferLike,
byteOffset?: number,
Expand Down
32 changes: 32 additions & 0 deletions src/http/http-codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* HTTP status code constants.
* Eliminates magic numbers when working with fetchResult and RequestBuilder.
*/
export const HttpCodes = {
// 2xx Success
Ok: 200,
Created: 201,
Accepted: 202,
NoContent: 204,
// 3xx Redirection
MovedPermanently: 301,
Found: 302,
NotModified: 304,
// 4xx Client Error
BadRequest: 400,
Unauthorized: 401,
Forbidden: 403,
NotFound: 404,
MethodNotAllowed: 405,
Conflict: 409,
Gone: 410,
UnprocessableEntity: 422,
TooManyRequests: 429,
// 5xx Server Error
InternalServerError: 500,
BadGateway: 502,
ServiceUnavailable: 503,
GatewayTimeout: 504,
} as const;

export type HttpCode = (typeof HttpCodes)[keyof typeof HttpCodes];
2 changes: 2 additions & 0 deletions src/http/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { fetchResult } from './fetch-result.js';
export type { HttpCode } from './http-codes.js';
export { HttpCodes } from './http-codes.js';
export { RequestBuilder } from './request-builder.js';
export { extractErrorDescription, httpStatusToError } from './status-mapper.js';
40 changes: 39 additions & 1 deletion src/result/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,8 @@ export const Result = {
*/
tapErrorFirst<T>(result: Result<T>, fn: (firstError: AppError) => void): Result<T> {
if (!result.ok && result.errors.length > 0) {
fn(result.errors[0] ?? Err.unexpected('Result.Empty', 'No errors found.'));
const [firstError] = result.errors;
if (firstError) fn(firstError);
}
return result;
},
Expand Down Expand Up @@ -774,6 +775,43 @@ export const Result = {
return { ok, err };
},

// ─── TRAVERSE ──────────────────────────────────────────────────────────────

/**
* Maps items through a Result-returning function, collecting ALL errors.
* Unlike `and(items.map(fn))`, this avoids creating an intermediate array of Results.
*/
traverse<A, B>(items: readonly A[], fn: (item: A) => Result<B>): Result<B[]> {
const values: B[] = [];
const errors: AppError[] = [];
for (const item of items) {
const r = fn(item);
if (r.ok) values.push(r.value);
else errors.push(...r.errors);
}
return errors.length > 0 ? Result.failureFrom<B[]>(errors) : Result.success(values);
},

/**
* Async version of traverse.
* Processes items sequentially; collects ALL errors.
*/
traverseAsync<A, B>(
items: readonly A[],
fn: (item: A) => Promise<Result<B>>,
): Promise<Result<B[]>> {
return (async () => {
const values: B[] = [];
const errors: AppError[] = [];
for (const item of items) {
const r = await fn(item);
if (r.ok) values.push(r.value);
else errors.push(...r.errors);
}
return errors.length > 0 ? Result.failureFrom<B[]>(errors) : Result.success(values);
})();
},

// ─── ASYNC SEQUENCE ────────────────────────────────────────────────────────

/**
Expand Down
73 changes: 73 additions & 0 deletions src/string/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* String casing utilities.
*
* All functions split the input into words by detecting:
* - camelCase / PascalCase boundaries
* - Consecutive uppercase runs (e.g. "XMLParser" → ["XML", "Parser"])
* - Whitespace, hyphens, underscores
*/

function splitWords(str: string): string[] {
return str
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.split(/[\s\-_]+/)
.filter((w) => w.length > 0);
}

/** "hello world" / "helloWorld" / "hello-world" → "HelloWorld" */
export function toPascalCase(str: string): string {
return splitWords(str)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join('');
}

/** "hello world" / "HelloWorld" / "hello-world" → "helloWorld" */
export function toCamelCase(str: string): string {
const words = splitWords(str);
return words
.map((w, i) =>
i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(),
)
.join('');
}

/** "hello world" / "helloWorld" / "Hello World" → "hello-world" */
export function toKebabCase(str: string): string {
return splitWords(str)
.map((w) => w.toLowerCase())
.join('-');
}

/** "hello world" / "helloWorld" / "hello-world" → "hello_world" */
export function toSnakeCase(str: string): string {
return splitWords(str)
.map((w) => w.toLowerCase())
.join('_');
}

/** "hello world" / "helloWorld" / "hello-world" → "HELLO_WORLD" */
export function toMacroCase(str: string): string {
return splitWords(str)
.map((w) => w.toUpperCase())
.join('_');
}

/** "hello world" / "helloWorld" / "hello_world" → "Hello-World" */
export function toTrainCase(str: string): string {
return splitWords(str)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join('-');
}

/** "hello world" / "helloWorld" / "hello-world" → "Hello World" */
export function toTitleCase(str: string): string {
return splitWords(str)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(' ');
}

/** "hello world" / "helloWorld" / "hello-world" → "_helloWorld" */
export function toUnderscoreCamelCase(str: string): string {
return `_${toCamelCase(str)}`;
}
Loading
Loading