An educational pure functional programming library in TypeScript
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix type errors in examples and harden guards with expanded tests

+166 -72
+1 -1
docs-site/src/content/docs/examples/task-queue.md
··· 455 455 flatMap((result) => 456 456 result === null 457 457 ? fail(JobError.timeout(job.id, TIMEOUT_MS)) 458 - : succeed(result) 458 + : succeed(undefined) 459 459 ), 460 460 461 461 // Error recovery - log and continue
+2 -2
examples/stories/beavers-big-system/02-adt-matching.ts
··· 160 160 * Using matchOr() for partial matching — only handle cases we care about. 161 161 */ 162 162 const isRabbitRelevant = (details: WorkDetails): boolean => 163 - matchOr(false)(details)({ 163 + matchOr<WorkDetails, boolean>(false)(details)({ 164 164 BurrowInspection: ({ occupant }) => occupant === "Rabbit", 165 165 }) 166 166 ··· 168 168 * Get water-related info (only applicable to some work types). 169 169 */ 170 170 const getWaterInfo = (details: WorkDetails): string => 171 - matchOr("N/A")(details)({ 171 + matchOr<WorkDetails, string>("N/A")(details)({ 172 172 DamRepair: ({ waterLevel }) => `Water level: ${waterLevel}m`, 173 173 }) 174 174
+10 -26
examples/stories/beavers-big-system/03-tracked-arrays.ts
··· 25 25 nonEmpty, 26 26 sortBy, 27 27 head, 28 - pipe, 29 28 } from "../../../src/index" 30 29 31 30 // ============================================================================= ··· 71 70 // Basic queue — we know nothing about it 72 71 type WorkQueue = Arr<WorkOrder> 73 72 74 - // Queue that's guaranteed non-empty — safe to call head() 75 - type NonEmptyQueue = Arr<WorkOrder, NonEmpty> 76 - 77 73 // Queue that's guaranteed sorted by priority 78 74 type SortedQueue = Arr<WorkOrder, Sorted> 79 75 ··· 96 92 * Add work to a sorted queue and maintain sort order. 97 93 * Returns NonEmpty | Sorted because we're adding an element. 98 94 */ 99 - const addToQueue = ( 100 - queue: SortedQueue, 95 + const addToQueue = <P extends string>( 96 + queue: Arr<WorkOrder, P | Sorted>, 101 97 work: WorkOrder, 102 98 ): ReadyQueue => 103 99 sortByPriority(arr([...queue, work])) as ReadyQueue ··· 106 102 * Get next work from a non-empty queue. 107 103 * REQUIRES NonEmpty — compiler won't let you call this on a possibly-empty queue. 108 104 */ 109 - const getNext = (queue: NonEmptyQueue): WorkOrder => head(queue) 110 - 111 - /** 112 - * Try to get next work, handling the possibly-empty case. 113 - * Returns Option — None if queue is empty. 114 - */ 115 - const tryGetNext = (queue: WorkQueue): Option<WorkOrder> => 116 - pipe(nonEmpty(queue), (opt) => 117 - match(opt)({ 118 - Some: ({ value }) => some(head(value)), 119 - None: () => none, 120 - }), 121 - ) 105 + const getNext = <P extends string>(queue: Arr<WorkOrder, P | NonEmpty>): WorkOrder => head(queue) 122 106 123 107 /** 124 108 * Process work from a possibly-empty queue. 125 109 * Demonstrates safe handling of the NonEmpty requirement. 126 110 */ 127 - const processQueue = ( 128 - queue: WorkQueue, 111 + const processQueue = <P extends string>( 112 + queue: Arr<WorkOrder, P>, 129 113 onWork: (work: WorkOrder) => void, 130 114 onEmpty: () => void, 131 115 ): void => ··· 173 157 p: number, 174 158 ): Option<WorkOrder> => 175 159 match(priority(p))({ 176 - Some: ({ value }) => some(workOrder(id, desc, value)), 177 - None: () => none, 160 + Some: ({ value }): Option<WorkOrder> => some(workOrder(id, desc, value)), 161 + None: (): Option<WorkOrder> => none, 178 162 }) 179 163 180 164 // Create some work orders ··· 242 226 const nextWork = getNext(fullQueue) 243 227 console.log(` Next: "${nextWork.description}" (priority ${nextWork.priority})`) 244 228 245 - // For a possibly-empty queue, we must use tryGetNext or processQueue 229 + // For a possibly-empty queue, we must use processQueue 246 230 console.log("\nHandling possibly-empty queue:") 247 231 248 232 const maybeEmptyQueue: WorkQueue = arr([]) ··· 309 293 console.log("=".repeat(60) + "\n") 310 294 311 295 // Simulate processing a work queue 312 - const processWorkQueue = (queue: WorkQueue): void => { 313 - let remaining = queue 296 + const processWorkQueue = <P extends string>(queue: Arr<WorkOrder, P>): void => { 297 + let remaining: Arr<WorkOrder> = arr([...queue]) 314 298 let processed = 0 315 299 316 300 const loop = (): void => {
+12 -10
examples/stories/forest-election/02-result.ts
··· 177 177 // Demo 178 178 // ============================================================================= 179 179 180 + type ElectionResult = { winner: Candidate; votes: number; total: number } 181 + 180 182 console.log("=== The Forest Election: Counting Day ===\n") 181 183 182 184 // Test 1: Normal election with clear winner ··· 192 194 ] 193 195 194 196 matchResult( 195 - (result) => 197 + (result: ElectionResult) => 196 198 console.log( 197 199 `Winner: ${result.winner} with ${result.votes}/${result.total} votes`, 198 200 ), 199 - (error) => console.log(`Error: ${formatError(error)}`), 201 + (error: CountError) => console.log(`Error: ${formatError(error)}`), 200 202 )(runElection(normalBallots)) 201 203 console.log() 202 204 203 205 // Test 2: Empty ballot box 204 206 console.log("--- Test 2: Empty ballot box ---") 205 207 matchResult( 206 - (result) => console.log(`Winner: ${result.winner}`), 207 - (error) => console.log(`Error: ${formatError(error)}`), 208 + (result: ElectionResult) => console.log(`Winner: ${result.winner}`), 209 + (error: CountError) => console.log(`Error: ${formatError(error)}`), 208 210 )(runElection([])) 209 211 console.log() 210 212 ··· 217 219 { voter: "Badger", candidate: "Squirrel" }, 218 220 ] 219 221 matchResult( 220 - (result) => console.log(`Winner: ${result.winner}`), 221 - (error) => console.log(`Error: ${formatError(error)}`), 222 + (result: ElectionResult) => console.log(`Winner: ${result.winner}`), 223 + (error: CountError) => console.log(`Error: ${formatError(error)}`), 222 224 )(runElection(twoWayTie)) 223 225 console.log() 224 226 ··· 230 232 { voter: "Beaver", candidate: "Heron" }, 231 233 ] 232 234 matchResult( 233 - (result) => console.log(`Winner: ${result.winner}`), 234 - (error) => console.log(`Error: ${formatError(error)}`), 235 + (result: ElectionResult) => console.log(`Winner: ${result.winner}`), 236 + (error: CountError) => console.log(`Error: ${formatError(error)}`), 235 237 )(runElection(threeWayTie)) 236 238 console.log() 237 239 ··· 257 259 258 260 const result = runElection(fullElection) 259 261 matchResult( 260 - (r) => { 262 + (r: ElectionResult) => { 261 263 console.log(`Winner: ${r.winner}`) 262 264 console.log(`Votes: ${r.votes} out of ${r.total}`) 263 265 console.log(`Percentage: ${((r.votes / r.total) * 100).toFixed(1)}%`) 264 266 }, 265 - (error) => console.log(`Error: ${formatError(error)}`), 267 + (error: CountError) => console.log(`Error: ${formatError(error)}`), 266 268 )(result) 267 269 268 270 // Show the counts
+2 -3
examples/stories/forest-election/03-effects.ts
··· 18 18 fail, 19 19 sleep, 20 20 flatMap, 21 - mapEff, 22 21 foldEff, 23 22 pipe, 24 23 runPromise, ··· 140 139 pipe( 141 140 sendWithRetry(region, bird, reliability, 3), 142 141 foldEff( 143 - (error) => succeed({ _tag: "Failed" as const, region, error }), 144 - (result) => succeed({ _tag: "Delivered" as const, result }), 142 + (error) => succeed<AnnouncementResult>({ _tag: "Failed" as const, region, error }), 143 + (result) => succeed<AnnouncementResult>({ _tag: "Delivered" as const, result }), 145 144 ), 146 145 ) 147 146
+2 -2
examples/task-queue/with-purus.ts
··· 111 111 112 112 // Test environment - silent logging for unit tests 113 113 // Just swap provide(prodEnv) with provide(testEnv) in tests 114 - const _testEnv: QueueEnv = { 114 + export const _testEnv: QueueEnv = { 115 115 logger: { 116 116 info: () => {}, 117 117 error: () => {}, ··· 211 211 flatMap((result) => 212 212 result === null 213 213 ? fail(JobError.timeout(job.id, TIMEOUT_MS)) 214 - : succeed(result) 214 + : succeed(undefined) 215 215 ), 216 216 217 217 // Error recovery - log and continue
+9 -5
examples/user-registration/with-purus.ts
··· 209 209 readonly failed: Array<{ input: UserInput; error: ApiError }> 210 210 } 211 211 212 + type CaptureResult = 213 + | { readonly ok: true; readonly user: User } 214 + | { readonly ok: false; readonly input: UserInput; readonly error: ApiError } 215 + 212 216 const createUsersBatch = ( 213 217 users: readonly UserInput[], 214 218 ): Eff<BatchResult, never, unknown> => { 215 219 // Wrap each createUser call to capture both success and failure 216 220 const createAndCapture = ( 217 221 input: UserInput, 218 - ): Eff<{ ok: true; user: User } | { ok: false; input: UserInput; error: ApiError }, never, unknown> => 222 + ): Eff<CaptureResult, never, unknown> => 219 223 pipe( 220 224 createUser(input), 221 225 foldEff( 222 226 // On failure: wrap error in success (so traversePar continues) 223 - (error) => succeed({ ok: false as const, input, error }), 227 + (error) => succeed<CaptureResult>({ ok: false, input, error }), 224 228 // On success: wrap user in success 225 - (user) => succeed({ ok: true as const, user }), 229 + (user) => succeed<CaptureResult>({ ok: true, user }), 226 230 ), 227 231 ) 228 232 ··· 281 285 282 286 matchValidation( 283 287 (_data) => console.log("Unexpectedly valid!"), 284 - (errors) => { 288 + (errors: readonly ValidationError[]) => { 285 289 console.log("Validation errors:") 286 290 console.log(errors.map((err) => ` - ${formatValidationError(err)}`).join("\n")) 287 291 }, ··· 302 306 console.log("Form valid, calling API...") 303 307 return createUser(data) 304 308 }, 305 - (errors): Eff<User, ApiError, unknown> => { 309 + (errors: readonly ValidationError[]): Eff<User, ApiError, unknown> => { 306 310 // Convert validation errors to API error for uniform handling 307 311 const msg = errors.map(formatValidationError).join("; ") 308 312 return fail(ApiError.network(`Validation failed: ${msg}`))
+1 -1
package.json
··· 1 1 { 2 2 "name": "purus-ts", 3 - "version": "0.1.0-alpha.9", 3 + "version": "0.1.0-alpha.10", 4 4 "description": "Pure TypeScript effect system with fiber-based concurrency, brands, refinements, and pattern matching", 5 5 "type": "module", 6 6 "main": "./dist/index.js",
+5 -4
src/data/guards.ts
··· 27 27 export const isArray = (x: unknown) => Array.isArray(x) 28 28 29 29 /** Type guard for Set values */ 30 - export const isSet = (x: unknown): x is Set<unknown> => x instanceof Set 30 + export const isSet = (x: unknown): x is ReadonlySet<unknown> => x instanceof Set 31 31 32 32 /** Type guard for Map values */ 33 - export const isMap = (x: unknown): x is Map<unknown, unknown> => x instanceof Map 33 + export const isMap = (x: unknown): x is ReadonlyMap<unknown, unknown> => 34 + x instanceof Map 34 35 35 36 // ============================================================================= 36 37 // Number Property Guards ··· 74 75 75 76 /** Combines two guards with logical AND - both must pass */ 76 77 export const and = 77 - <A, B extends A, C extends B>(g1: (x: A) => x is B, g2: (x: B) => x is C) => 78 - (x: A): x is C => 78 + <A, B extends A, C extends A>(g1: (x: A) => x is B, g2: (x: A) => x is C) => 79 + (x: A): x is B & C => 79 80 g1(x) && g2(x) 80 81 81 82 /** Combines two guards with logical OR - either must pass */
+120 -17
tests/guards.test.ts
··· 2 2 import { 3 3 and, 4 4 hasProperties, 5 + hasProperty, 6 + isArray, 5 7 isBoolean, 8 + isDefined, 6 9 isEmpty, 10 + isFiniteNumber, 11 + isInteger, 12 + isMap, 13 + isNegative, 7 14 isNonEmpty, 8 - isDefined, 9 15 isNotNull, 10 16 isNotNullish, 11 17 isNumber, 18 + isObject, 12 19 isPositive, 20 + isSet, 13 21 isString, 22 + not, 14 23 or, 24 + size, 15 25 } from "../src/index" 16 26 17 27 describe("Guards", () => { 18 28 describe("primitive guards", () => { 19 29 it("isString checks for strings", () => { 20 30 expect(isString("hello")).toBe(true) 31 + expect(isString("")).toBe(true) 21 32 expect(isString(123)).toBe(false) 33 + expect(isString(null)).toBe(false) 22 34 }) 23 35 24 36 it("isNumber checks for numbers", () => { 25 37 expect(isNumber(42)).toBe(true) 38 + expect(isNumber(0)).toBe(true) 39 + expect(isNumber(NaN)).toBe(true) 26 40 expect(isNumber("42")).toBe(false) 27 41 }) 28 42 29 43 it("isBoolean checks for booleans", () => { 30 44 expect(isBoolean(true)).toBe(true) 45 + expect(isBoolean(false)).toBe(true) 31 46 expect(isBoolean(0)).toBe(false) 32 47 }) 48 + 49 + it("isObject checks for non-null objects", () => { 50 + expect(isObject({})).toBe(true) 51 + expect(isObject([])).toBe(true) 52 + expect(isObject(null)).toBe(false) 53 + expect(isObject(undefined)).toBe(false) 54 + expect(isObject("string")).toBe(false) 55 + }) 56 + 57 + it("isArray checks for arrays", () => { 58 + expect(isArray([])).toBe(true) 59 + expect(isArray([1, 2])).toBe(true) 60 + expect(isArray({})).toBe(false) 61 + expect(isArray("string")).toBe(false) 62 + }) 63 + 64 + it("isSet checks for Sets", () => { 65 + expect(isSet(new Set())).toBe(true) 66 + expect(isSet(new Set([1, 2]))).toBe(true) 67 + expect(isSet([])).toBe(false) 68 + expect(isSet({})).toBe(false) 69 + }) 70 + 71 + it("isMap checks for Maps", () => { 72 + expect(isMap(new Map())).toBe(true) 73 + expect(isMap(new Map([["a", 1]]))).toBe(true) 74 + expect(isMap({})).toBe(false) 75 + expect(isMap(new Set())).toBe(false) 76 + }) 77 + }) 78 + 79 + describe("number property guards", () => { 80 + it("isPositive checks x > 0", () => { 81 + expect(isPositive(5)).toBe(true) 82 + expect(isPositive(0)).toBe(false) 83 + expect(isPositive(-1)).toBe(false) 84 + expect(isPositive("5")).toBe(false) 85 + }) 86 + 87 + it("isNegative checks x < 0", () => { 88 + expect(isNegative(-1)).toBe(true) 89 + expect(isNegative(0)).toBe(false) 90 + expect(isNegative(5)).toBe(false) 91 + expect(isNegative("-1")).toBe(false) 92 + }) 93 + 94 + it("isInteger checks for whole numbers", () => { 95 + expect(isInteger(42)).toBe(true) 96 + expect(isInteger(0)).toBe(true) 97 + expect(isInteger(-3)).toBe(true) 98 + expect(isInteger(3.14)).toBe(false) 99 + expect(isInteger("42")).toBe(false) 100 + }) 101 + 102 + it("isFiniteNumber excludes Infinity and NaN", () => { 103 + expect(isFiniteNumber(42)).toBe(true) 104 + expect(isFiniteNumber(0)).toBe(true) 105 + expect(isFiniteNumber(Infinity)).toBe(false) 106 + expect(isFiniteNumber(-Infinity)).toBe(false) 107 + expect(isFiniteNumber(NaN)).toBe(false) 108 + expect(isFiniteNumber("42")).toBe(false) 109 + }) 33 110 }) 34 111 35 112 describe("nullability guards", () => { ··· 63 140 }) 64 141 }) 65 142 143 + describe("guard combinators", () => { 144 + it("and combines guards with intersection", () => { 145 + const isPositiveNumber = and(isNumber, isPositive) 146 + expect(isPositiveNumber(5)).toBe(true) 147 + expect(isPositiveNumber(-5)).toBe(false) 148 + expect(isPositiveNumber("5")).toBe(false) 149 + }) 150 + 151 + it("or combines guards with union", () => { 152 + const isStringOrNumber = or(isString, isNumber) 153 + expect(isStringOrNumber("hello")).toBe(true) 154 + expect(isStringOrNumber(42)).toBe(true) 155 + expect(isStringOrNumber(true)).toBe(false) 156 + }) 157 + 158 + it("not negates a guard", () => { 159 + const isNotString = not(isString) 160 + expect(isNotString(42)).toBe(true) 161 + expect(isNotString(null)).toBe(true) 162 + expect(isNotString("hello")).toBe(false) 163 + }) 164 + }) 165 + 66 166 describe("property guards", () => { 167 + it("hasProperty checks for a single key", () => { 168 + const hasId = hasProperty("id") 169 + expect(hasId({ id: 1 })).toBe(true) 170 + expect(hasId({ name: "Alice" })).toBe(false) 171 + expect(hasId(null)).toBe(false) 172 + expect(hasId("string")).toBe(false) 173 + }) 174 + 67 175 it("hasProperties checks for all specified keys", () => { 68 176 const hasIdAndName = hasProperties("id", "name") 69 177 expect(hasIdAndName({ id: 1, name: "Alice" })).toBe(true) ··· 75 183 }) 76 184 77 185 describe("emptiness predicates", () => { 186 + it("size returns element count", () => { 187 + expect(size("hello")).toBe(5) 188 + expect(size("")).toBe(0) 189 + expect(size([1, 2, 3])).toBe(3) 190 + expect(size([])).toBe(0) 191 + expect(size(new Set([1, 2]))).toBe(2) 192 + expect(size(new Map([["a", 1]]))).toBe(1) 193 + expect(size({ a: 1, b: 2 })).toBe(2) 194 + expect(size({})).toBe(0) 195 + }) 196 + 78 197 it("isEmpty checks strings", () => { 79 198 expect(isEmpty("")).toBe(true) 80 199 expect(isEmpty("hello")).toBe(false) ··· 105 224 expect(isNonEmpty("hi")).toBe(true) 106 225 expect(isNonEmpty([])).toBe(false) 107 226 expect(isNonEmpty([1])).toBe(true) 108 - }) 109 - }) 110 - 111 - describe("compound guards", () => { 112 - it("and combines guards with intersection", () => { 113 - const isPositiveNumber = and(isNumber, isPositive) 114 - expect(isPositiveNumber(5)).toBe(true) 115 - expect(isPositiveNumber(-5)).toBe(false) 116 - expect(isPositiveNumber("5")).toBe(false) 117 - }) 118 - 119 - it("or combines guards with union", () => { 120 - const isStringOrNumber = or(isString, isNumber) 121 - expect(isStringOrNumber("hello")).toBe(true) 122 - expect(isStringOrNumber(42)).toBe(true) 123 - expect(isStringOrNumber(true)).toBe(false) 124 227 }) 125 228 }) 126 229 })
+2 -1
tsconfig.test.json
··· 4 4 "rootDir": ".", 5 5 "noEmit": true 6 6 }, 7 - "include": ["src/**/*.ts", "tests/**/*.ts"] 7 + "include": ["src/**/*.ts", "tests/**/*.ts", "examples/**/*.ts"], 8 + "exclude": ["node_modules", "dist", "examples/**/without-purus.ts"] 8 9 }