From 4ee1510e38244bc9419c3a00fd3191f1899e1703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Mon, 3 Aug 2026 14:36:45 +0300 Subject: [PATCH 1/2] fix(types): make documented API patterns compile under strict TS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type-only changes — zero runtime behavior change, all 1079 tests pass. - Union.match/is/get/partition/groupBy: infer from the union value (U extends { tag; value }) instead of Union, which the compiler cannot invert — the documented destructuring match pattern previously failed to compile against the published d.ts under strict mode - These.isLeft/isRight/isBoth: now type guards that narrow to the Left/Right/Both member - Eq.struct / Ord.struct: constraint relaxed to A extends object so interfaces are accepted (Record rejected them) - tsconfig.test.json: inherited exclude nullified the tests include, so tests were never typechecked; add explicit exclude + vitest/globals - tests: adapt union tests to as-Shape literals (assignment narrowing), fix noUncheckedIndexedAccess access in these tests - JSDoc examples in function/record/these/union corrected to versions that actually compile (verified against the packaged build) Co-Authored-By: Claude Fable 5 --- src/eq/index.ts | 4 +-- src/function/index.ts | 8 ++--- src/ord/index.ts | 4 +-- src/record/index.ts | 2 +- src/these/index.ts | 27 ++++++++------ src/union/union.ts | 74 +++++++++++++++++++++++---------------- tests/these/these.test.ts | 2 +- tests/union/union.test.ts | 27 +++++++------- tsconfig.test.json | 6 ++-- 9 files changed, 85 insertions(+), 69 deletions(-) diff --git a/src/eq/index.ts b/src/eq/index.ts index e3d50e6..45284a5 100644 --- a/src/eq/index.ts +++ b/src/eq/index.ts @@ -71,9 +71,7 @@ export function contramap(eqA: Eq, f: (b: B) => A): Eq { * @example * const eqPoint = struct({ x: Eq.number, y: Eq.number }); */ -export function struct>( - eqs: { [K in keyof A]: Eq }, -): Eq { +export function struct(eqs: { [K in keyof A]: Eq }): Eq { return { equals: (first, second) => { for (const key of Object.keys(eqs)) { diff --git a/src/function/index.ts b/src/function/index.ts index 5e58770..8d81b2e 100644 --- a/src/function/index.ts +++ b/src/function/index.ts @@ -16,8 +16,8 @@ * // Railway-oriented pipeline * const r = pipe( * Result.success(10), - * Result.map(n => n * 2), - * Result.ensure(n => n > 10, Err.validation('Value.TooSmall', 'Too small')), + * (res) => Result.map(res, (n) => n * 2), + * (res) => Result.ensure(res, (n) => n > 10, Err.validation('Value.TooSmall', 'Too small')), * ); */ export function pipe(a: A): A; @@ -195,8 +195,8 @@ export function pipe(a: unknown, ...fns: ReadonlyArray<(x: unknown) => unknown>) * @example * // Reusable pipeline * const validateAge = flow( - * Result.map((u: User) => u.age), - * Result.ensure(a => a >= 18, Err.validation('Age.Underage', 'Must be 18+')), + * (r: Result) => Result.map(r, (u) => u.age), + * (r) => Result.ensure(r, (a) => a >= 18, Err.validation('Age.Underage', 'Must be 18+')), * ); */ export function flow, B>(ab: (...a: A) => B): (...a: A) => B; diff --git a/src/ord/index.ts b/src/ord/index.ts index 60c539b..ded8bb6 100644 --- a/src/ord/index.ts +++ b/src/ord/index.ts @@ -105,9 +105,7 @@ export function contramap(ordA: Ord, f: (b: B) => A): Ord { * age: Ord.number, * }); */ -export function struct>( - ords: { [K in keyof A]: Ord }, -): Ord { +export function struct(ords: { [K in keyof A]: Ord }): Ord { const keys = Object.keys(ords) as Array; return { equals: (first, second) => { diff --git a/src/record/index.ts b/src/record/index.ts index 021cd3f..7028da8 100644 --- a/src/record/index.ts +++ b/src/record/index.ts @@ -7,7 +7,7 @@ * const users = { a: { name: 'Alice' }, b: { name: 'Bob' } }; * * const names = R.map(users, u => u.name); // { a: 'Alice', b: 'Bob' } - * const withCharlie = R.upsert(users, 'c', { name: 'Charlie' }); + * const renamed = R.upsert(users, 'b', { name: 'Bobby' }); // key must belong to the record's key union */ // ─── Query ─────────────────────────────────────────────────────────────────── diff --git a/src/these/index.ts b/src/these/index.ts index 6576624..d340f7a 100644 --- a/src/these/index.ts +++ b/src/these/index.ts @@ -9,12 +9,13 @@ * to collect as much information as possible. * * @example - * import { These, Result } from 'tsentials/these'; + * import { These } from 'tsentials/these'; + * import { Err, type AppError } from 'tsentials/errors'; * - * const parseAge = (raw: string): These => { + * const parseAge = (raw: string): These => { * const age = Number(raw); - * if (Number.isNaN(age)) return These.left(Err.validation('Age.NaN', 'Not a number')); - * if (age < 0) return These.both(Err.validation('Age.Negative', 'Negative age'), 0); + * if (Number.isNaN(age)) return These.left([Err.validation('Age.NaN', 'Not a number')]); + * if (age < 0) return These.both([Err.validation('Age.Negative', 'Clamped to 0')], 0); * return These.right(age); * }; */ @@ -52,18 +53,24 @@ export function both(error: E, value: A): These { // ─── Type guards ───────────────────────────────────────────────────────────── -/** Checks if the These is a Left. */ -export function isLeft(these: These): boolean { +/** Checks if the These is a Left — narrows the type on success. */ +export function isLeft( + these: These, +): these is { readonly _tag: 'Left'; readonly left: E } { return these._tag === 'Left'; } -/** Checks if the These is a Right. */ -export function isRight(these: These): boolean { +/** Checks if the These is a Right — narrows the type on success. */ +export function isRight( + these: These, +): these is { readonly _tag: 'Right'; readonly right: A } { return these._tag === 'Right'; } -/** Checks if the These is a Both. */ -export function isBoth(these: These): boolean { +/** Checks if the These is a Both — narrows the type on success. */ +export function isBoth( + these: These, +): these is { readonly _tag: 'Both'; readonly left: E; readonly right: A } { return these._tag === 'Both'; } diff --git a/src/union/union.ts b/src/union/union.ts index 15c9e70..2002da9 100644 --- a/src/union/union.ts +++ b/src/union/union.ts @@ -16,18 +16,25 @@ * failed: { error: AppError }; * }>; * - * const result: PaymentResult = { tag: 'success', value: { transactionId: 'txn_123' } }; - * - * const message = Union.match(result, { - * success: ({ transactionId }) => `Paid! Ref: ${transactionId}`, - * pending: ({ estimatedMs }) => `Pending for ${estimatedMs}ms`, - * failed: ({ error }) => `Failed: ${error.description}`, - * }); + * function toMessage(result: PaymentResult): string { + * return Union.match(result, { + * success: ({ transactionId }) => `Paid! Ref: ${transactionId}`, + * pending: ({ estimatedMs }) => `Pending for ${estimatedMs}ms`, + * failed: ({ error }) => `Failed: ${error.description}`, + * }); + * } */ export type Union> = { [K in keyof T]: { readonly tag: K; readonly value: T[K] }; }[keyof T]; +/** + * Any tagged value — the shape every Union member conforms to. + * Used so utilities can infer directly from the union value itself + * (inferring T back out of Union is not possible for the compiler). + */ +type Tagged = { readonly tag: PropertyKey; readonly value: unknown }; + /** * Utilities for working with Union values. */ @@ -44,9 +51,9 @@ export const Union = { * TypeScript ensures all cases are handled at compile time. * */ - match, R>( - union: Union, - handlers: { [K in keyof T]: (value: T[K]) => R }, + match( + union: U, + handlers: { [K in U['tag']]: (value: Extract['value']) => R }, ): R { const handler = (handlers as Record R>)[ union.tag as string | symbol @@ -58,21 +65,24 @@ export const Union = { /** * Type guard — checks if the union has a specific tag. */ - is, K extends keyof T>( - union: Union, + is( + union: U, tag: K, - ): union is { tag: K; value: T[K] } { + ): union is Extract { return union.tag === tag; }, /** * Extracts the value for a specific tag, throws otherwise. */ - get, K extends keyof T>(union: Union, tag: K): T[K] { + get( + union: U, + tag: K, + ): Extract['value'] { if (union.tag !== tag) { throw new Error(`Expected union tag '${String(tag)}' but got '${String(union.tag)}'.`); } - return union.value as T[K]; + return union.value as Extract['value']; }, /** @@ -89,16 +99,20 @@ export const Union = { * // lefts: Array<{ r: number }> * // rights: Array<{ w: number; h: number }> */ - partition, K1 extends keyof T, K2 extends keyof T>( - items: ReadonlyArray>, + partition( + items: ReadonlyArray, leftTag: K1, rightTag: K2, - ): { lefts: Array; rights: Array } { - const lefts: Array = []; - const rights: Array = []; + ): { + lefts: Array['value']>; + rights: Array['value']>; + } { + const lefts: Array['value']> = []; + const rights: Array['value']> = []; for (const item of items) { - if (item.tag === leftTag) lefts.push(item.value as T[K1]); - else if (item.tag === rightTag) rights.push(item.value as T[K2]); + if (item.tag === leftTag) lefts.push(item.value as Extract['value']); + else if (item.tag === rightTag) + rights.push(item.value as Extract['value']); } return { lefts, rights }; }, @@ -112,17 +126,17 @@ export const Union = { * groups.circle // Array<{ r: number }> * groups.rect // Array<{ w: number; h: number }> */ - groupBy>( - items: ReadonlyArray>, - ): { [K in keyof T]?: Array } { - const result: { [K in keyof T]?: Array } = {}; + groupBy( + items: ReadonlyArray, + ): { [K in U['tag']]?: Array['value']> } { + const result: Record> = {}; for (const item of items) { - const key = item.tag as keyof T; + const key = item.tag; if (!result[key]) { - result[key] = [] as Array; + result[key] = []; } - (result[key] as Array).push(item.value as T[typeof key]); + result[key].push(item.value); } - return result; + return result as { [K in U['tag']]?: Array['value']> }; }, } as const; diff --git a/tests/these/these.test.ts b/tests/these/these.test.ts index 4099395..48bf17a 100644 --- a/tests/these/these.test.ts +++ b/tests/these/these.test.ts @@ -168,6 +168,6 @@ describe('These.partition', () => { expect(lefts).toHaveLength(1); expect(rights).toHaveLength(1); expect(boths).toHaveLength(1); - expect(boths[0].value).toBe(2); + expect(boths[0]?.value).toBe(2); }); }); diff --git a/tests/union/union.test.ts b/tests/union/union.test.ts index b030ebe..3e30229 100644 --- a/tests/union/union.test.ts +++ b/tests/union/union.test.ts @@ -28,7 +28,7 @@ describe('Union.of', () => { describe('Union.match', () => { it('calls the correct handler', () => { - const circle: Shape = { tag: 'circle', value: { radius: 10 } }; + const circle = { tag: 'circle', value: { radius: 10 } } as Shape; const area = Union.match(circle, { circle: ({ radius }) => Math.PI * radius * radius, rect: ({ width, height }) => width * height, @@ -38,7 +38,7 @@ describe('Union.match', () => { }); it('dispatches rect correctly', () => { - const rect: Shape = { tag: 'rect', value: { width: 4, height: 5 } }; + const rect = { tag: 'rect', value: { width: 4, height: 5 } } as Shape; const area = Union.match(rect, { circle: ({ radius }) => Math.PI * radius * radius, rect: ({ width, height }) => width * height, @@ -48,7 +48,7 @@ describe('Union.match', () => { }); it('dispatches triangle correctly', () => { - const triangle: Shape = { tag: 'triangle', value: { base: 10, height: 4 } }; + const triangle = { tag: 'triangle', value: { base: 10, height: 4 } } as Shape; const area = Union.match(triangle, { circle: ({ radius }) => Math.PI * radius * radius, rect: ({ width, height }) => width * height, @@ -70,7 +70,7 @@ describe('Union.match', () => { }); it('returns different types from handlers', () => { - const rect: Shape = { tag: 'rect', value: { width: 4, height: 5 } }; + const rect = { tag: 'rect', value: { width: 4, height: 5 } } as Shape; const result = Union.match(rect, { circle: () => 'circle', rect: () => 42, @@ -82,12 +82,12 @@ describe('Union.match', () => { describe('Union.is', () => { it('returns true for matching tag', () => { - const circle: Shape = { tag: 'circle', value: { radius: 5 } }; + const circle = { tag: 'circle', value: { radius: 5 } } as Shape; expect(Union.is(circle, 'circle')).toBe(true); }); it('returns false for non-matching tag', () => { - const circle: Shape = { tag: 'circle', value: { radius: 5 } }; + const circle = { tag: 'circle', value: { radius: 5 } } as Shape; expect(Union.is(circle, 'rect')).toBe(false); }); @@ -101,18 +101,18 @@ describe('Union.is', () => { describe('Union.get', () => { it('returns value for matching tag', () => { - const rect: Shape = { tag: 'rect', value: { width: 3, height: 4 } }; + const rect = { tag: 'rect', value: { width: 3, height: 4 } } as Shape; const value = Union.get(rect, 'rect'); expect(value.width).toBe(3); }); it('throws for non-matching tag', () => { - const circle: Shape = { tag: 'circle', value: { radius: 5 } }; + const circle = { tag: 'circle', value: { radius: 5 } } as Shape; expect(() => Union.get(circle, 'rect')).toThrow("Expected union tag 'rect' but got 'circle'."); }); it('throws with correct message for another mismatch', () => { - const triangle: Shape = { tag: 'triangle', value: { base: 10, height: 5 } }; + const triangle = { tag: 'triangle', value: { base: 10, height: 5 } } as Shape; expect(() => Union.get(triangle, 'circle')).toThrow( "Expected union tag 'circle' but got 'triangle'.", ); @@ -144,7 +144,8 @@ describe('Union.partition', () => { }); it('returns empty arrays for an empty input', () => { - const { lefts, rights } = Union.partition([], 'circle', 'rect'); + const empty: Shape[] = []; + const { lefts, rights } = Union.partition(empty, 'circle', 'rect'); expect(lefts).toEqual([]); expect(rights).toEqual([]); }); @@ -182,11 +183,7 @@ describe('Union.groupBy', () => { }); it('returns empty object for empty input', () => { - const groups = Union.groupBy<{ - circle: { radius: number }; - rect: { width: number; height: number }; - triangle: { base: number; height: number }; - }>([]); + const groups = Union.groupBy([]); expect(groups).toEqual({}); }); diff --git a/tsconfig.test.json b/tsconfig.test.json index efc81b3..b08e44e 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -2,7 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "rootDir": ".", - "noEmit": true + "noEmit": true, + "types": ["vitest/globals"] }, - "include": ["src/**/*", "tests/**/*"] + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] } From a8dff99ef07b16da8b7a32c0894df36942d796a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Mon, 3 Aug 2026 14:37:01 +0300 Subject: [PATCH 2/2] docs: document all 19 modules, verify every example, add Context7 widget All code examples were extracted, typechecked (strict + exactOptionalPropertyTypes + noUncheckedIndexedAccess) and executed against the npm-packed build. - CLAUDE.md: add the 8 previously undocumented modules (function, eq, ord, predicate, array, these, tree, record) with API reference - README.md: expand fp module sections to full API; fix These example (toResult needs These), Union.of example, and the fromAsync/bindIfAsync signatures shown; document the as-Shape literal pattern for exhaustive Union.match - AGENTS.md: add string module, HttpCodes, Result.traverse, Union.partition/groupBy, pattern sections for the 8 fp modules, and new naming-pitfall rows (assignment narrowing, These error arrays, fromAsync takes Promise>) - docs/index.html: add tsentials/string module card and case-conversion example, make snippets self-contained, fix the These/Ord/Maybe/Rules examples, embed the Context7 chat widget - prose pass: rewrite dash-spliced sentences and clipped fragments into plain sentences across all four files Co-Authored-By: Claude Fable 5 --- AGENTS.md | 139 ++++++++++++++++++++++++++++++++- CLAUDE.md | 159 +++++++++++++++++++++++++++++++++++-- README.md | 204 +++++++++++++++++++++++++++++++++++++----------- docs/index.html | 67 +++++++++++++--- 4 files changed, 501 insertions(+), 68 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 17c1954..6180640 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Quick Reference **Package:** `tsentials` (npm) -**Purpose:** Railway-oriented programming — error-as-value, no exceptions +**Purpose:** Railway-oriented programming: errors as values instead of exceptions **Node:** ≥18, TypeScript ≥5.0, ESM only ## Import Paths @@ -16,7 +16,8 @@ import { RuleEngine } from 'tsentials/rules'; import type { Rule } from 'tsentials/rules'; import { createEntityBase, createSoftDeletable } from 'tsentials/entity'; import type { DomainEvent } from 'tsentials/entity'; -import { fetchResult, RequestBuilder } from 'tsentials/http'; +import { fetchResult, RequestBuilder, HttpCodes } from 'tsentials/http'; +import type { HttpCode } from 'tsentials/http'; import { SystemDateTimeProvider, createFakeDateTimeProvider } from 'tsentials/time'; import { deepClone, cloneArray } from 'tsentials/clone'; import { Union } from 'tsentials/union'; @@ -30,6 +31,7 @@ import { NonEmptyArray, head, asNonEmptyArray } from 'tsentials/array'; import { These } from 'tsentials/these'; import { Tree } from 'tsentials/tree'; import { Record } from 'tsentials/record'; +import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase, toMacroCase, toTrainCase, toTitleCase, toUnderscoreCamelCase } from 'tsentials/string'; ``` --- @@ -91,6 +93,8 @@ Result.always(r, fn) // unconditional cleanup — returns fn result Result.ap(fab, fa) // applicative apply Result.partition(results) // split into { ok: T[], err: AppError[] } Result.sequence(promises) // await Promise>[] → Result +Result.traverse(items, fn) // A[] → (A → Result) → Result, collects ALL errors +await Result.traverseAsync(items, async fn) // async version ``` ### Async Pipeline — fromAsync / ResultAsync\ @@ -283,6 +287,11 @@ await RequestBuilder.post('/users') // Status → ErrorType: 400/422→Validation, 401→Unauthorized, 403→Forbidden, // 404/410→NotFound, 409/429→Conflict, ≥500→Unexpected + +// Type-safe status constants — no magic numbers (21 constants, 2xx–5xx) +const status: HttpCode = HttpCodes.Ok; // 200 +HttpCodes.NotFound // 404 +HttpCodes.InternalServerError // 500 ``` ### Entity Base (DDD) @@ -321,7 +330,9 @@ type PaymentResult = Union<{ failed: { error: AppError }; }>; -const r: PaymentResult = { tag: 'success', value: { transactionId: 'txn_123' } }; +// Use `as PaymentResult` (NOT `: PaymentResult`) for fresh literals — +// assignment narrowing would pin the value to one member and break exhaustive match. +const r = { tag: 'success', value: { transactionId: 'txn_123' } } as PaymentResult; Union.match(r, { success: ({ transactionId }) => `Paid: ${transactionId}`, @@ -329,8 +340,12 @@ Union.match(r, { failed: ({ error }) => `Failed: ${error.description}`, }); -Union.is(r, 'success') // type guard +Union.is(r, 'success') // type guard — narrows to the tagged member Union.get(r, 'success') // value or throws + +// Collection utilities +Union.partition(items, 'leftTag', 'rightTag') // { lefts: Left[], rights: Right[] } — other tags discarded +Union.groupBy(items) // { [tag]?: value[] } ``` ### Clone — deepClone & cloneArray @@ -398,6 +413,119 @@ parseAndValidate(raw, isUser) Result.then(safeJsonParse(raw), data => validatePayload(data)); ``` +### Function — pipe & flow + +```typescript +import { pipe, flow, identity, constant, flip } from 'tsentials/function'; + +pipe(5, n => n * 2, n => String(n)) // "10" — value through unary fns (max 15 steps) +const f = flow((n: number) => n * 2, n => String(n)) // reusable composition +identity(x) / constant(v)() / flip(binaryFn) +``` + +### Eq\ & Ord\ + +```typescript +import { Eq } from 'tsentials/eq'; +import { Ord, sortBy, min, max, clamp, between, reverse } from 'tsentials/ord'; + +// Instances: Eq.strict/string/number/boolean/date — Ord.number/string/boolean/date +Eq.struct({ id: Eq.number, name: Eq.string }) // structural equality (works with interfaces) +Eq.contramap(Eq.number, (u: User) => u.id) // equality by projection +Eq.getArrayEq(Eq.number) // element-wise + +const byAge = Ord.contramap(Ord.number, (u: User) => u.age); +sortBy(users, byAge) / sortBy(users, reverse(byAge)) +min(byAge, a, b) / max(byAge, a, b) +clamp(Ord.number, 0, 100, 150) // 100 — throws if lower > upper +between(Ord.number, 0, 100, 42) // true +Ord.struct({ name: Ord.string, age: Ord.number }) // multi-field, short-circuits +``` + +### Predicate\ + +```typescript +import { Predicate } from 'tsentials/predicate'; + +const isAdult = Predicate.from((u: User) => u.age >= 18); // { test(value): boolean } +Predicate.and(p1, p2) / Predicate.or(p1, p2) / Predicate.not(p) +Predicate.all(...ps) / Predicate.any(...ps) +Predicate.refinement((v: unknown): v is string => typeof v === 'string') // narrows on .test() +``` + +### NonEmptyArray\ + +```typescript +import { NonEmptyArray, isNonEmpty, prepend, append, head, tail, last, init, asNonEmptyArray } from 'tsentials/array'; + +const items: NonEmptyArray = ['a', 'b']; +head(items) / last(items) // safe — no Maybe +isNonEmpty(plain) // type guard → NonEmptyArray +prepend(head, arr) / append(arr, last) // construct +asNonEmptyArray([]) // Maybe.none +NonEmptyArray.map/reverse/sort // preserve guarantee; filter → plain array +``` + +### These\ — partial success + +```typescript +import { These } from 'tsentials/these'; + +// Left (errors) | Right (value) | Both (value AND errors) +// Use These — toResult expects the error side to be an array +These.left([err]) / These.right(value) / These.both([err], value) +These.isLeft(t) / These.isRight(t) / These.isBoth(t) // type guards — narrow +These.map / mapLeft / flatMap / tap / tapLeft +These.match(t, onLeft, onRight, onBoth) // exhaustive +These.toResult(t) // Both → failure (errors win) +These.toResultLenient(t) // Both → success (errors discarded) +These.fromResult(r) +These.partition(theses) // { lefts, rights, boths } +``` + +### Tree\ + +```typescript +import { Tree, drawTree } from 'tsentials/tree'; + +const t = Tree.of('root', [Tree.leaf('a'), Tree.of('b', [Tree.leaf('b1')])]); +Tree.toArray(t) // pre-order values +Tree.size(t) / Tree.isLeaf(t) / Tree.root(t) / Tree.children(t) +Tree.map(t, fn) / Tree.filter(t, pred) // filter: parent kept if any descendant matches +Tree.find(t, pred) / Tree.findAll(t, pred) +Tree.fold(t, (value, childResults) => ...) // post-order +drawTree(t) // ├── └── pretty print +``` + +### Record utilities + +```typescript +import { Record as R } from 'tsentials/record'; + +R.keys / R.values / R.entries / R.has / R.size / R.isEmpty +R.map(rec, (v, k) => ...) / R.mapWithKey(rec, (k, v) => [newKey, v]) +R.filter(rec, pred) / R.filterMap(rec, v => mappedOrNull) +R.upsert(rec, key, value) // NOTE: key must belong to the record's key union +R.remove / R.pick / R.omit +R.reduce(rec, initial, (acc, v, k) => ...) / R.partition(rec, pred) +``` + +### String — case conversion + +```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('helloWorld') // 'HELLO_WORLD' +toTrainCase('hello_world') // 'Hello-World' +toTitleCase('helloWorld') // 'Hello World' +toUnderscoreCamelCase('helloWorld') // '_helloWorld' +// Handles spaces, hyphens, underscores, camelCase, PascalCase, and mixed input +``` + --- ## Important: Naming Pitfalls @@ -411,6 +539,9 @@ Result.then(safeJsonParse(raw), data => validatePayload(data)); | `error.message` | `error.description` | AppError property is `.description` | | `JSON.parse(raw)` | `safeJsonParse(raw)` | Never throws | | `import { Err } from 'tsentials/result'` | `import { Err } from 'tsentials/errors'` | Wrong module | +| `const u: Shape = { tag, value }` then `Union.match(u, ...)` | `const u = { tag, value } as Shape` | Assignment narrowing breaks exhaustive match | +| `These` with `These.toResult` | `These` | `toResult` expects an error array | +| `fromAsync(promiseOfT)` | `fromAsync(promiseOfResultT)` | Takes `Promise>`, not `Promise` | --- diff --git a/CLAUDE.md b/CLAUDE.md index be5af73..3352855 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # tsentials — Developer Guide Railway-oriented programming toolkit for TypeScript. -Modules: `result`, `maybe`, `errors`, `rules`, `entity`, `http`, `time`, `clone`, `union`, `json`, `string`. +Modules: `result`, `maybe`, `errors`, `rules`, `entity`, `http`, `time`, `clone`, `union`, `json`, `string`, `function`, `array`, `eq`, `ord`, `predicate`, `these`, `tree`, `record`. ## Commands @@ -32,6 +32,14 @@ src/ union/ — Union discriminated union utility json/ — Json types, isJson/isJsonObject guards, safeJsonParse(), safeJsonStringify(), parseAndValidate() string/ — String case conversion (toPascalCase, toCamelCase, toKebabCase, toSnakeCase, toMacroCase, toTrainCase, toTitleCase, toUnderscoreCamelCase) + function/ — pipe, flow, identity, constant, flip — function composition + array/ — NonEmptyArray, head/tail/last/init, isNonEmpty, asNonEmptyArray + eq/ — Eq equality type class (strict/string/number/boolean/date, contramap, struct, getArrayEq) + ord/ — Ord ordering type class (sortBy, min, max, clamp, between, reverse, contramap, struct) + predicate/ — Predicate, Refinement (from, and, or, not, all, any) + these/ — These partial success (Left | Right | Both), Result bridge + tree/ — Tree recursive hierarchy (map, filter, find, fold, drawTree) + record/ — Functional object utilities (map, filter, pick, omit, reduce, partition, upsert) ``` ### Result @@ -92,7 +100,8 @@ await Result.traverseAsync(items, async fn) // async version chain(Result.success(5)).bind(fn).map(fn).ensure(pred, err).match(ok, err) // Async pipeline — one await at the end, andThen() NOT then() -await fromAsync(promise) +// fromAsync takes Promise>, NOT Promise +await fromAsync(promiseOfResult) .andThen(fn) // monadic bind .map(fn) .ensure(pred, err) @@ -115,7 +124,7 @@ await Result.alwaysAsync(r, async fn) - `error.description` — NOT `.message` (AppError uses `description`) ### Maybe -Functional namespace — all static methods, no class instantiation. +Functional namespace: all static methods, no class instantiation. ```typescript import { Maybe, tryFirst, tryLast, tryFind, choose, asMaybe } from 'tsentials/maybe'; @@ -195,7 +204,7 @@ RuleEngine.evaluateAsync(asyncRule, ctx) // Promise ``` ### Entity (DDD) -Mixin factory pattern — no deep inheritance. +Mixin factory pattern instead of deep inheritance. ```typescript import { createEntityBase, createSoftDeletable } from 'tsentials/entity'; @@ -264,9 +273,11 @@ import { Union } from 'tsentials/union'; type Shape = Union<{ circle: { radius: number }; rect: { w: number; h: number } }>; -const s: Shape = { tag: 'circle', value: { radius: 5 } }; +// NOTE: use `as Shape` (not `: Shape`) for fresh literals — assignment narrowing +// would otherwise narrow the value to one member and break exhaustive match. +const s = { tag: 'circle', value: { radius: 5 } } as Shape; Union.match(s, { circle: ({ radius }) => radius * 2, rect: ({ w, h }) => w * h }); -Union.is(s, 'circle') // type guard +Union.is(s, 'circle') // type guard — narrows to the tagged member Union.get(s, 'circle') // value or throws // Collection utilities @@ -275,7 +286,7 @@ Union.groupBy(items) // → { [tag]: value[] } ``` ### Json -Safe JSON parsing — returns `Result`, never throws. +Safe JSON parsing that returns `Result` instead of throwing. ```typescript import { safeJsonParse, safeJsonStringify, parseAndValidate } from 'tsentials/json'; @@ -324,13 +335,145 @@ toTitleCase('helloWorld') // "Hello World" toUnderscoreCamelCase('helloWorld') // "_helloWorld" ``` +### Function (pipe & flow) + +```typescript +import { pipe, flow, identity, constant, flip } from 'tsentials/function'; + +pipe(5, n => n * 2, n => n + 1, n => String(n)) // "11" — value through unary fns (up to 15 steps) +const f = flow((n: number) => n * 2, n => String(n)); f(5) // "10" — reusable composition +identity(42) // 42 +constant(true)() // true — always returns the captured value +flip((a: number, b: number) => a - b)(3, 10) // 7 — reverses binary fn args +``` + +### Eq\ & Ord\ + +Type classes: `Eq = { equals(a, b) }`, `Ord extends Eq` adds `compare(a, b): -1 | 0 | 1`. + +```typescript +import { Eq } from 'tsentials/eq'; +import { Ord, sortBy, min, max, clamp, between, reverse } from 'tsentials/ord'; + +// Primitive instances: Eq.strict/string/number/boolean/date, Ord.number/string/boolean/date +const eqUser = Eq.struct({ id: Eq.number, name: Eq.string }); // structural equality +Eq.contramap(Eq.number, (u: User) => u.id) // compare by projection +Eq.getArrayEq(Eq.number) // element-wise array equality + +const byAge = Ord.contramap(Ord.number, (u: User) => u.age); +sortBy(users, byAge) // sorted copy (ascending) +sortBy(users, reverse(byAge)) // descending +min(byAge, a, b) / max(byAge, a, b) +clamp(Ord.number, 0, 100, 150) // 100 — throws if lower > upper +between(Ord.number, 0, 100, 42) // true +Ord.struct({ name: Ord.string, age: Ord.number }) // field-by-field, short-circuits +``` + +### Predicate\ + +`Predicate = { test(value): boolean }` — composable boolean logic. + +```typescript +import { Predicate } from 'tsentials/predicate'; + +const isAdult = Predicate.from((u: User) => u.age >= 18); +Predicate.and(p1, p2) / Predicate.or(p1, p2) / Predicate.not(p) +Predicate.all(p1, p2, p3) // every predicate must pass +Predicate.any(p1, p2, p3) // at least one must pass +Predicate.refinement((v: unknown): v is string => typeof v === 'string') // narrows on .test() +``` + +### NonEmptyArray\ + +```typescript +import { NonEmptyArray, isNonEmpty, prepend, append, head, tail, last, init, asNonEmptyArray } from 'tsentials/array'; + +const items: NonEmptyArray = ['a', 'b', 'c']; +head(items) / last(items) // safe — no Maybe, no null check +tail(items) / init(items) // plain arrays +isNonEmpty(plainArray) // type guard, narrows to NonEmptyArray +prepend(0, [1, 2]) / append([1, 2], 3) // construct NonEmptyArray +asNonEmptyArray([]) // Maybe.none — safe conversion +NonEmptyArray.map(items, fn) // preserves non-empty guarantee (also: reverse, sort) +NonEmptyArray.filter(items, fn) // plain array — filtering may empty it +``` + +### These\ + +Partial success: `Left` (errors only) | `Right` (value only) | `Both` (value AND errors). +For the `Result` bridge, use `These`, since `toResult` expects the error side to be an array. + +```typescript +import { These } from 'tsentials/these'; +import { Err, type AppError } from 'tsentials/errors'; + +const parseAge = (raw: string): These => { + const age = Number(raw); + if (Number.isNaN(age)) return These.left([Err.validation('Age.NaN', 'Not a number')]); + if (age < 0) return These.both([Err.validation('Age.Negative', 'Clamped to 0')], 0); + return These.right(age); +}; + +These.isLeft(t) / These.isRight(t) / These.isBoth(t) // type guards — narrow +These.map(t, fn) / These.mapLeft(t, fn) / These.flatMap(t, fn) +These.tap(t, fn) / These.tapLeft(t, fn) +These.match(t, onLeft, onRight, onBoth) // exhaustive +These.getRight(t) / These.getLeft(t) // value | undefined +These.toResult(t) // Both → failure (errors win) +These.toResultLenient(t) // Both → success (errors discarded) +These.fromResult(r) // Result → These +These.partition(theses) // { lefts: E[], rights: A[], boths: {error, value}[] } +``` + +### Tree\ + +`Tree = { value: T; forest: Tree[] }` — recursive hierarchies. + +```typescript +import { Tree, drawTree } from 'tsentials/tree'; + +const t = Tree.of('Electronics', [ + Tree.of('Phones', [Tree.leaf('iPhone'), Tree.leaf('Android')]), + Tree.leaf('Laptops'), +]); + +Tree.toArray(t) // ['Electronics', 'Phones', 'iPhone', 'Android', 'Laptops'] (pre-order) +Tree.toArrayWithDepth(t) // [{ value, depth }, ...] +Tree.size(t) // 5 +Tree.map(t, fn) // preserves structure +Tree.filter(t, pred) // Tree | null — parent kept if any descendant matches +Tree.find(t, pred) // first matching node (depth-first) | null +Tree.findAll(t, pred) // all matching nodes +Tree.fold(t, (value, childResults) => ...) // post-order reduction +drawTree(t) // pretty-printed with ├── └── lines +``` + +### Record utilities + +Functional operations on plain objects. NOTE: `upsert`'s key must belong to the record's key union. + +```typescript +import { Record as R } from 'tsentials/record'; + +R.keys(rec) / R.values(rec) / R.entries(rec) // typed arrays +R.has(rec, key) / R.size(rec) / R.isEmpty(rec) +R.map(rec, (v, k) => ...) // preserves keys +R.mapWithKey(rec, (k, v) => [newKey, v]) // can rename keys +R.filter(rec, pred) // Partial> +R.filterMap(rec, v => mappedOrNull) // map + drop nullish results +R.upsert(rec, key, value) // immutable insert/update +R.remove(rec, key) / R.pick(rec, ...keys) / R.omit(rec, ...keys) +R.reduce(rec, initial, (acc, v, k) => ...) +R.partition(rec, pred) // { pass, fail } +``` + ## 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 1079 tests across 33 test files. +Vitest. `npm test` runs all 1079 tests across 33 test files. Test files mirror src/ structure under `tests/`. ## Publishing diff --git a/README.md b/README.md index fe7107d..3eacd20 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@ [![TypeScript](https://img.shields.io/badge/TypeScript-5.0%2B-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) [![Node.js](https://img.shields.io/badge/node-%3E%3D18-339933?style=flat-square&logo=node.js)](https://nodejs.org) -Railway-oriented programming for TypeScript — `Result`, `Maybe`, Rule Engine, and DDD base classes with full async pipeline support. +Railway-oriented programming for TypeScript: `Result`, `Maybe`, Rule Engine, and DDD base classes with full async pipeline support. --- > **[Your Function Signature Is Lying →](https://www.senrecep.com/en/blog/your-function-signature-is-lying)** -> A deep dive into why `try/catch` falls short in TypeScript, the philosophy behind Railway Oriented Programming, and the design decisions that shaped `tsentials`. +> Why `try/catch` falls short in TypeScript, the thinking behind Railway Oriented Programming, and the design decisions that shaped `tsentials`. --- @@ -98,7 +98,7 @@ npm install tsentials ## Result\ -Discriminated union `{ ok: true; value: T } | { ok: false; errors: AppError[] }`. No exceptions — errors are values. +Discriminated union `{ ok: true; value: T } | { ok: false; errors: AppError[] }`. Errors are values, not exceptions. ### Creating Results @@ -211,12 +211,13 @@ const [ok, value, errors] = Result.deconstruct(result); ### Async Pipeline — ResultAsync\ -`ResultAsync` implements `PromiseLike>` — the entire chain builds synchronously, resolves once at the end with a single `await`. +`ResultAsync` implements `PromiseLike>`. The entire chain builds synchronously and resolves once at the end with a single `await`. ```typescript import { fromAsync } from 'tsentials/result'; import { Err } from 'tsentials/errors'; +// fromAsync takes a Promise> — here fetchUser returns Promise> const profile = await fromAsync(fetchUser(userId)) .andThen(user => validateUser(user)) .ensure(user => user.isActive, Err.validation('User.Inactive', 'Not active')) @@ -231,18 +232,19 @@ const profile = await fromAsync(fetchUser(userId)) Async variants of all sync operations are available: `thenAsync`, `mapAsync`, `ensureAsync`, `tapAsync`, `tapErrorAsync`, `compensateAsync`, `mapErrorAsync`. ```typescript -// Conditional async bind +// Conditional async bind — the bind fn preserves T; when the condition is +// false the original Result passes through unchanged await Result.bindIfAsync( Result.success(user), u => u.isAdmin, - async u => fetchAdminDashboard(u), + async u => loadAdminProfile(u), // (u: User) => Promise> ); -// Async recovery +// Async recovery — the recovery fn returns the same Result await Result.recoverAsync( Result.failure(cacheMiss), e => e.code === 'Cache.Miss', - async () => fetchFromDatabase(), + async () => fetchFromDatabase(), // () => Promise> ); ``` @@ -294,7 +296,7 @@ await Result.traverseAsync([1, 2], async n => fetchUser(n)); ## Maybe\ -Explicit optional values — no accidental `undefined`. +Explicit optional values instead of accidental `undefined`. ### Creating Maybe Values @@ -520,7 +522,7 @@ class Order implements EntityBase, SoftDeletable { ## HTTP (fetchResult) -`fetchResult` never throws — network errors and HTTP error responses are captured as `Result`. +`fetchResult` never throws. It captures network errors and HTTP error responses as `Result`. ```typescript import { fetchResult, RequestBuilder } from 'tsentials/http'; @@ -583,6 +585,7 @@ Programmatic discriminated union with exhaustive match. ```typescript import { Union } from 'tsentials/union'; +import type { AppError } from 'tsentials/errors'; type PaymentResult = Union<{ success: { transactionId: string }; @@ -590,7 +593,9 @@ type PaymentResult = Union<{ failed: { error: AppError }; }>; -const result = Union.of<{ success: { transactionId: string }; pending: { estimatedMs: number }; failed: { error: AppError } }>('success', { transactionId: 'txn_123' }); +// Construct with `as PaymentResult` (not `: PaymentResult`) for fresh literals — +// assignment narrowing would otherwise pin the value to a single member. +const result = { tag: 'success', value: { transactionId: 'txn_123' } } as PaymentResult; const message = Union.match(result, { success: ({ transactionId }) => `Paid! Ref: ${transactionId}`, @@ -598,7 +603,7 @@ const message = Union.match(result, { failed: ({ error }) => `Failed: ${error.description}`, }); -// Type guard +// Type guard — narrows to the tagged member if (Union.is(result, 'success')) { console.log(result.value.transactionId); } @@ -606,13 +611,21 @@ if (Union.is(result, 'success')) { // Unsafe extraction const id = Union.get(result, 'success').transactionId; // throws if wrong tag +// Collection utilities +type Shape = Union<{ circle: { radius: number }; rect: { w: number; h: number } }>; +const shapes: Shape[] = [ + { tag: 'circle', value: { radius: 1 } }, + { tag: 'rect', value: { w: 2, h: 3 } }, + { tag: 'circle', value: { radius: 4 } }, +]; + // 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 }> +// lefts: Array<{ radius: number }> (2 items), rights: Array<{ w: number; h: number }> (1 item) // groupBy: group all items by tag into a record const groups = Union.groupBy(shapes); -// { circle: [...], rect: [...] } +// { circle: [{ radius: 1 }, { radius: 4 }], rect: [{ w: 2, h: 3 }] } ``` --- @@ -644,8 +657,8 @@ import { deepClone, cloneArray } from 'tsentials/clone'; import type { Cloneable } from 'tsentials/clone'; ``` -`deepClone` uses the native `structuredClone` API when available, falling back to a robust -recursive implementation. Never throws — works in React Native (Hermes) and all JS runtimes. +`deepClone` uses the native `structuredClone` API when available and falls back to a recursive +implementation when it is not. It never throws, and it works in React Native (Hermes) and other JS runtimes. ```typescript // Plain objects, nested structures @@ -683,7 +696,7 @@ const cloned = cloneArray([new Product(1), new Product(2)]); ## JSON Utilities -Type-safe JSON parsing and validation that returns `Result` — no exceptions, fits directly into the railway pipeline. +Type-safe JSON parsing and validation that returns `Result` instead of throwing, so it fits directly into the railway pipeline. ```typescript import { safeJsonParse, safeJsonStringify, parseAndValidate } from 'tsentials/json'; @@ -750,8 +763,9 @@ const processed = Result.then( ## pipe & flow ```typescript -import { pipe, flow } from 'tsentials/function'; +import { pipe, flow, identity, constant, flip } from 'tsentials/function'; +// pipe — thread a value through unary functions (up to 15 steps, fully typed) const result = pipe( 5, n => n * 2, @@ -759,46 +773,87 @@ const result = pipe( n => String(n), ); // "11" +// flow — compose functions into a reusable pipeline const doubleAndStringify = flow( (n: number) => n * 2, n => String(n), ); doubleAndStringify(5); // "10" + +identity(42); // 42 +const alwaysTrue = constant(true); +alwaysTrue(); // true +const subtract = (a: number, b: number) => a - b; +flip(subtract)(3, 10); // 7 — arguments reversed ``` ## NonEmptyArray\ -Type-safe arrays guaranteed to have at least one element. No null checks needed for `head()` or `last()`. +Type-safe arrays guaranteed to have at least one element, so `head()` and `last()` never need a null check. ```typescript -import { NonEmptyArray, asNonEmptyArray } from 'tsentials/array'; +import { NonEmptyArray, asNonEmptyArray, isNonEmpty, prepend, append, head, tail, last, init } from 'tsentials/array'; const items: NonEmptyArray = ['a', 'b', 'c']; -NonEmptyArray.head(items); // 'a' — safe, no Maybe -NonEmptyArray.last(items); // 'c' +head(items); // 'a' — safe, no Maybe +last(items); // 'c' +tail(items); // ['b', 'c'] — plain array +init(items); // ['a', 'b'] — plain array // Safe conversion from plain array const maybe = asNonEmptyArray([]); // None const sure = asNonEmptyArray([1, 2]); // Some([1, 2]) + +// Type guard — narrows a plain array +const values = [1, 2, 3]; +if (isNonEmpty(values)) { + head(values); // 1 — no null check needed inside the guard +} + +// Construction that preserves the guarantee +prepend(0, [1, 2]); // NonEmptyArray [0, 1, 2] +append([1, 2], 3); // NonEmptyArray [1, 2, 3] + +// map/reverse/sort keep the non-empty guarantee; filter returns a plain array +NonEmptyArray.map(items, s => s.toUpperCase()); // NonEmptyArray ['A', 'B', 'C'] +NonEmptyArray.filter(items, s => s !== 'a'); // ['b', 'c'] — may become empty ``` ## Eq\ & Ord\ -Composable, type-safe equality and ordering. +Composable, type-safe equality and ordering. `Eq` provides `equals`, `Ord` extends it with `compare` returning `-1 | 0 | 1`. ```typescript import { Eq } from 'tsentials/eq'; -import { Ord, sortBy, min, max, clamp } from 'tsentials/ord'; +import { Ord, sortBy, min, max, clamp, between, reverse } from 'tsentials/ord'; interface User { readonly id: number; readonly name: string; readonly age: number; } +// Structural equality from primitive instances (Eq.strict/string/number/boolean/date) const eqUser = Eq.struct({ id: Eq.number, name: Eq.string, age: Eq.number }); +eqUser.equals({ id: 1, name: 'A', age: 30 }, { id: 1, name: 'A', age: 30 }); // true + +// Compare by projection +const eqById = Eq.contramap(Eq.number, (u: User) => u.id); +const eqNumberArray = Eq.getArrayEq(Eq.number); // element-wise array equality + +const users: User[] = [ + { id: 1, name: 'Carol', age: 35 }, + { id: 2, name: 'Alice', age: 30 }, +]; const byAge = Ord.contramap(Ord.number, (u: User) => u.age); -const sorted = sortBy(users, byAge); +sortBy(users, byAge); // sorted copy, ascending +sortBy(users, reverse(byAge)); // descending -min(byAge, userA, userB); -clamp(Ord.number, 0, 100, 150); // 100 +const [a, b] = [users[0]!, users[1]!]; +min(byAge, a, b); // Alice (30) +max(byAge, a, b); // Carol (35) +clamp(Ord.number, 0, 100, 150); // 100 +between(Ord.number, 0, 100, 42); // true + +// Multi-field ordering — compares fields in order, short-circuits +const byNameThenAge = Ord.struct({ name: Ord.string, age: Ord.number }); ``` ## Predicate\ @@ -808,28 +863,60 @@ Composable boolean predicates for validation and filtering. ```typescript import { Predicate } from 'tsentials/predicate'; +interface User { readonly age: number; readonly isActive: boolean; readonly role: string; } + const isAdult = Predicate.from((u: User) => u.age >= 18); const isActive = Predicate.from((u: User) => u.isActive); +const isAdmin = Predicate.from((u: User) => u.role === 'admin'); const isValid = Predicate.and(isAdult, isActive); -const isAnyOf = Predicate.any(isAdult, isGuest, isAdmin); +isValid.test({ age: 20, isActive: true, role: 'user' }); // true + +Predicate.or(isAdult, isAdmin); // either passes +Predicate.not(isAdult); // negation +Predicate.all(isAdult, isActive, isAdmin); // every predicate must pass +Predicate.any(isAdult, isActive, isAdmin); // at least one must pass + +// Refinement — narrows the type on success +const isString = Predicate.refinement((v: unknown): v is string => typeof v === 'string'); +const input: unknown = 'hello'; +if (isString.test(input)) input.toUpperCase(); // input is string here ``` ## These\ -Partial success — a value together with errors/warnings. Unlike `Result` which is either-or, `These` allows both. +Partial success: a value together with errors or warnings. `Result` is either-or; `These` allows both at once. Use `These` when you want the `Result` bridge, since `toResult` expects the error side to be an array. ```typescript import { These } from 'tsentials/these'; +import { Err, type AppError } from 'tsentials/errors'; -const parseAge = (raw: string): These => { +const parseAge = (raw: string): These => { const age = Number(raw); - if (Number.isNaN(age)) return These.left(Err.validation('Age.NaN', 'Not a number')); - if (age < 0) return These.both(Err.validation('Age.Negative', 'Negative age'), 0); + if (Number.isNaN(age)) return These.left([Err.validation('Age.NaN', 'Not a number')]); + if (age < 0) return These.both([Err.validation('Age.Negative', 'Clamped to 0')], 0); return These.right(age); }; -These.toResult(parseAge('-5')); // failure (Both converts to failure) +// Exhaustive match — Left, Right, and Both each get a handler +const label = These.match( + parseAge('-5'), + (errors) => `failed: ${errors[0]?.code}`, + (age) => `ok: ${age}`, + (errors, age) => `partial: ${age} with ${errors.length} warning(s)`, +); // "partial: 0 with 1 warning(s)" + +// Type guards narrow +const t = parseAge('30'); +if (These.isRight(t)) console.log(t.right); // 30 + +// Bridge to Result +These.toResult(parseAge('-5')); // failure — Both converts to failure (errors win) +These.toResultLenient(parseAge('-5')); // success(0) — Both keeps the value, discards errors + +// Split a batch into successes / failures / partials +const { lefts, rights, boths } = These.partition([parseAge('30'), parseAge('abc'), parseAge('-5')]); +// rights: [30], lefts: [[Age.NaN]], boths: [{ error: [...], value: 0 }] ``` ## Tree\ @@ -837,16 +924,31 @@ These.toResult(parseAge('-5')); // failure (Both converts to failure) Recursive tree data structure for hierarchies. ```typescript -import { Tree } from 'tsentials/tree'; +import { Tree, drawTree } from 'tsentials/tree'; -const tree = Tree.of('root', [ - Tree.of('a', [Tree.leaf('a1')]), - Tree.leaf('b'), +const tree = Tree.of('Electronics', [ + Tree.of('Phones', [Tree.leaf('iPhone'), Tree.leaf('Android')]), + Tree.leaf('Laptops'), ]); -Tree.toArray(tree); // ['root', 'a', 'a1', 'b'] -Tree.find(tree, v => v === 'a1'); -Tree.drawTree(tree); +Tree.toArray(tree); // ['Electronics', 'Phones', 'iPhone', 'Android', 'Laptops'] (pre-order) +Tree.size(tree); // 5 +Tree.map(tree, s => s.toUpperCase()); // same structure, transformed values +Tree.find(tree, v => v === 'iPhone'); // first matching node (depth-first) | null +Tree.findAll(tree, v => v.length > 6); // all matching nodes +Tree.filter(tree, v => v === 'Phones'); // parent kept if any descendant matches + +// Post-order fold — e.g. count all nodes +Tree.fold(tree, (_value, children: readonly number[]) => + 1 + children.reduce((a, b) => a + b, 0), +); // 5 + +console.log(drawTree(tree)); +// Electronics +// ├── Phones +// │ ├── iPhone +// │ └── Android +// └── Laptops ``` ## Record Utilities @@ -858,10 +960,24 @@ import { Record as R } from 'tsentials/record'; const users = { a: { name: 'Alice' }, b: { name: 'Bob' } }; -R.map(users, u => u.name); // { a: 'Alice', b: 'Bob' } -R.filter(users, u => u.name !== 'Bob'); -R.pick(users, 'a'); // { a: { name: 'Alice' } } -R.omit(users, 'b'); // { a: { name: 'Alice' } } +R.map(users, u => u.name); // { a: 'Alice', b: 'Bob' } +R.filter(users, u => u.name !== 'Bob'); // { a: { name: 'Alice' } } +R.pick(users, 'a'); // { a: { name: 'Alice' } } +R.omit(users, 'b'); // { a: { name: 'Alice' } } +R.keys(users); // ['a', 'b'] — typed +R.size(users); // 2 +R.has(users, 'a'); // true + +const scores = { math: 90, art: 40, science: 75 }; + +R.reduce(scores, 0, (acc, v) => acc + v); // 205 +R.partition(scores, v => v >= 60); // { pass: { math, science }, fail: { art } } +R.filterMap(scores, v => (v >= 60 ? v + 10 : null)); // { math: 100, science: 85 } +R.mapWithKey(scores, (k, v) => [k.toUpperCase(), v]); // { MATH: 90, ART: 40, SCIENCE: 75 } + +// upsert/remove — immutable; the key must belong to the record's key union +R.upsert(scores, 'art', 55); // { math: 90, art: 55, science: 75 } +R.remove(scores, 'art'); // { math: 90, science: 75 } ``` ## String Utilities diff --git a/docs/index.html b/docs/index.html index 3b76ebe..08cf8bf 100644 --- a/docs/index.html +++ b/docs/index.html @@ -179,7 +179,7 @@
Railway-Oriented Programming

tsentials

- Result<T>, Maybe<T>, Rule Engine, DDD base classes, type-safe JSON, and functional utilities — with full async pipeline support. + Result<T>, Maybe<T>, Rule Engine, DDD base classes, type-safe JSON, and functional utilities, with full async pipeline support.

@@ -209,7 +209,7 @@

tsentials

Railway-Oriented

-

Chain operations that automatically short-circuit on failure — no try/catch.

+

Chain operations that short-circuit on failure instead of throwing.

@@ -218,7 +218,7 @@

Railway-Oriented

Async-first

-

ResultAsync<T> chains build synchronously, resolve once. Zero intermediate awaits.

+

ResultAsync<T> chains build synchronously and resolve with a single await at the end.

@@ -254,7 +254,7 @@

DDD-ready

Zero dependencies

-

No runtime dependencies. Pure TypeScript that compiles to clean ESM.

+

Pure TypeScript with no runtime dependencies, compiled to clean ESM.

@@ -263,7 +263,7 @@

Zero dependencies

Type-safe JSON

-

safeJsonParse and parseAndValidate return Result<T> — never throw, chain directly into any pipeline.

+

safeJsonParse and parseAndValidate return Result<T> instead of throwing, so they chain into any pipeline.

@@ -344,6 +344,10 @@

# Modules

tsentials/record
Functional object utilities — map, filter, pick, omit, reduce, partition
+
+
tsentials/string
+
Case conversion — toPascalCase, toCamelCase, toKebabCase, toSnakeCase, toMacroCase, toTrainCase, toTitleCase
+
@@ -357,8 +361,10 @@

# Examples

Async pipeline — ResultAsync<T> -
import { fromAsync, Err } from 'tsentials/result';
+      
import { fromAsync } from 'tsentials/result';
+import { Err } from 'tsentials/errors';
 
+// fromAsync takes a Promise<Result<T>> — fetchUser: (id) => Promise<Result<User>>
 const profile = await fromAsync(fetchUser(userId))
   .andThen(user => validateUser(user))       // Result<User> | ResultAsync<User>
   .ensure(user => user.isActive, Err.validation('User.Inactive', 'Not active'))
@@ -377,6 +383,14 @@ 

# Examples

import { Maybe, tryFind, choose } from 'tsentials/maybe';
 
+interface User { nickname: string | null; email: string; role: string; displayName?: string; }
+
+const users: User[] = [
+  { nickname: null, email: 'trinity@zion.io', role: 'admin' },
+  { nickname: '  neo  ', email: 'neo@zion.io', role: 'user', displayName: 'Neo' },
+];
+const user = users[1]!;
+
 // Wrap nullable, chain operations
 const display = Maybe.getOrElse(
   Maybe.filter(
@@ -398,6 +412,10 @@ 

# Examples

import { RuleEngine } from 'tsentials/rules';
 import type { Rule } from 'tsentials/rules';
+import { Result } from 'tsentials/result';
+import { Err } from 'tsentials/errors';
+
+interface User { age: number; email: string | null; }
 
 const isAdult: Rule<User> = ctx =>
   ctx.age >= 18 ? Result.ok() : Result.failure(Err.validation('Age.TooLow', 'Must be 18+'));
@@ -426,6 +444,8 @@ 

# Examples

} // Parse + type guard → fully typed Result<User> +interface User { name: string; age: number; } + function isUser(v: unknown): v is User { return isJsonObject(v) && typeof v.name === 'string' && typeof v.age === 'number'; } @@ -478,16 +498,18 @@

# Examples

These<E,A> — partial success
import { These } from 'tsentials/these';
+import { Err, type AppError } from 'tsentials/errors';
 
-// A value together with warnings/errors
-const parseAge = (raw: string): These<AppError, number> => {
+// A value together with warnings/errors — These<AppError[], A> bridges to Result
+const parseAge = (raw: string): These<AppError[], number> => {
   const age = Number(raw);
-  if (Number.isNaN(age)) return These.left(Err.validation('Age.NaN', 'Not a number'));
-  if (age < 0) return These.both(Err.validation('Age.Negative', 'Negative age'), 0);
+  if (Number.isNaN(age)) return These.left([Err.validation('Age.NaN', 'Not a number')]);
+  if (age < 0) return These.both([Err.validation('Age.Negative', 'Clamped to 0')], 0);
   return These.right(age);
 };
 
-These.toResult(parseAge('-5')); // failure (Both → failure)
+These.toResult(parseAge('-5')); // failure (Both → failure, errors win) +These.toResultLenient(parseAge('-5')); // success(0) — errors discarded
@@ -499,12 +521,30 @@

# Examples

interface User { readonly age: number; } +const users: User[] = [{ age: 35 }, { age: 25 }, { age: 30 }]; + const byAge = Ord.contramap(Ord.number, (u: User) => u.age); -sortBy(users, byAge); // sorted by age ascending +sortBy(users, byAge); // [{ age: 25 }, { age: 30 }, { age: 35 }] clamp(Ord.number, 0, 100, 150); // 100
+
+
+ + String — case conversion +
+
import { toPascalCase, toKebabCase, toSnakeCase, toMacroCase, toTitleCase } from 'tsentials/string';
+
+toPascalCase('hello world');  // 'HelloWorld'
+toKebabCase('HelloWorld');    // 'hello-world'
+toSnakeCase('helloWorld');    // 'hello_world'
+toMacroCase('helloWorld');    // 'HELLO_WORLD'
+toTitleCase('helloWorld');    // 'Hello World'
+
+// Handles spaces, hyphens, underscores, camelCase, PascalCase, and mixed input
+
+ @@ -529,5 +569,8 @@

# Examples

} + + +