skyr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,513 @@
1
+ # skyr
2
+
3
+ > Skyr is a thick, protein-rich, and nutritious traditional Icelandic dairy
4
+ > product.
5
+
6
+ **skyr** is a lightweight TypeScript library for functional error handling with
7
+ seamless async support and optional dependency injection.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ # npm (coming soon)
13
+ npm install skyr
14
+
15
+ # bun
16
+ bun add skyr
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### Basic Results
22
+
23
+ A `Result` can either be **ok** or it can **fail**. When ok, the result contains
24
+ a value of whatever type you need. When failed, it contains a `Failure` - a
25
+ structured error with a `code`, `message`, and optional `cause` for debugging.
26
+
27
+ ```typescript
28
+ import { fail, ok } from "skyr";
29
+
30
+ type User = { id: string; email: string; passwordHash: string };
31
+
32
+ // Simple validation
33
+ function validateEmail(email: string) {
34
+ if (!email.includes("@")) {
35
+ return fail("INVALID_EMAIL", "Email must contain @");
36
+ }
37
+ return ok(email);
38
+ } // => Result<string, Failure<"INVALID_EMAIL">>
39
+
40
+ // Check the result
41
+ const result = validateEmail("user@example.com");
42
+
43
+ if (result.isOk()) {
44
+ console.log("Valid email:", result.unwrap());
45
+ } else {
46
+ console.log("Error:", result.unwrap().message);
47
+ }
48
+ ```
49
+
50
+ Use `.isOk()` and `.hasFailed()` to check which variant you have, then
51
+ `.unwrap()` to extract the value or the failure.
52
+
53
+ ### Transforming Results
54
+
55
+ Transform ok values with `.map()`, which automatically skips if the result has
56
+ failed.
57
+
58
+ ```typescript
59
+ function normalizeEmail(email: string) {
60
+ return email.toLowerCase().trim();
61
+ }
62
+
63
+ const result = validateEmail("User@Example.com")
64
+ .map(normalizeEmail)
65
+ .map((email) => `Welcome, ${email}!`); // Result<string, Failure<"INVALID_EMAIL">>
66
+
67
+ // Pattern matching handles both cases
68
+ const message = result.match({
69
+ ok: (greeting) => greeting,
70
+ failed: (error) => `Error: ${error.message}`,
71
+ });
72
+
73
+ console.log(message);
74
+ ```
75
+
76
+ ### Handling Specific Errors
77
+
78
+ Use `.mapFailure()` with an object to handle specific error codes—perfect for
79
+ recovery or transforming specific failures while letting others pass through.
80
+
81
+ ```typescript
82
+ type AppError =
83
+ | Failure<"NOT_FOUND">
84
+ | Failure<"TIMEOUT">
85
+ | Failure<"AUTH_FAILED">;
86
+
87
+ declare function fetchUser(id: string): Result<User, AppError>;
88
+
89
+ const result = fetchUser("123").mapFailure({
90
+ // TypeScript autocompletes available codes: "NOT_FOUND", "TIMEOUT", "AUTH_FAILED"
91
+ "NOT_FOUND": () => ok(guestUser), // Recovery
92
+ "TIMEOUT": (err) => fail("RETRY")(err.message), // Transform
93
+ // AUTH_FAILED not handled - passes through unchanged
94
+ });
95
+ // Result<User | GuestUser, Failure<"RETRY"> | Failure<"AUTH_FAILED">>
96
+ ```
97
+
98
+ Each handler:
99
+ - Gets autocomplete for available error codes
100
+ - Receives the narrowed `Failure<"CODE">` type
101
+ - Can return `ok(value)` for recovery or `fail(code)(message)` to transform
102
+ - Unhandled codes automatically pass through
103
+
104
+ This works with async results too:
105
+
106
+ ```typescript
107
+ const result = await fetchUserAsync("123").mapFailure({
108
+ "NOT_FOUND": () => ok(guestUser),
109
+ "TIMEOUT": () => fromThrowable(retryFetch("123")),
110
+ });
111
+ ```
112
+
113
+ ### Async Operations
114
+
115
+ When you work with Promises, skyr automatically returns an `AsyncResult` - and
116
+ it stays async until you await it.
117
+
118
+ ```typescript
119
+ import { wrapThrowable } from "skyr";
120
+
121
+ // Simulate database lookup (might throw or reject)
122
+ async function findUserInDb(email: string) {
123
+ const user = await db.query("SELECT * FROM users WHERE email = ?", [email]);
124
+ if (!user) throw new Error("User not found");
125
+ return user;
126
+ }
127
+
128
+ // Wrap throwing async function to return Results
129
+ const findUser = wrapThrowable(
130
+ findUserInDb,
131
+ (cause) => ({
132
+ code: "USER_NOT_FOUND",
133
+ message: "Unable to locate user",
134
+ cause,
135
+ }),
136
+ ); // => (email: string) => AsyncResult<User, Failure<"USER_NOT_FOUND">>
137
+
138
+ // Chain async operations
139
+ const userResult = validateEmail("user@example.com")
140
+ .map(normalizeEmail)
141
+ .map((email) => findUser(email)); // AsyncResult<User, Failure<"INVALID_EMAIL"> | Failure<"USER_NOT_FOUND">>
142
+
143
+ // Await to get the Result
144
+ const user = await userResult.unwrap();
145
+ ```
146
+
147
+ `wrapThrowable()` wraps a function to catch any errors and convert them to
148
+ Failures. Once a Result becomes async, it stays async—this is called "async
149
+ poison".
150
+
151
+ ### Better Type Inference with `fn()`
152
+
153
+ The `fn()` helper provides cleaner type inference for functions.
154
+
155
+ ```typescript
156
+ import { fn } from "skyr";
157
+
158
+ const validatePassword = fn((password: string) => {
159
+ if (password.length < 8) {
160
+ return fail("WEAK_PASSWORD", "Password must be at least 8 characters");
161
+ }
162
+ return ok(password);
163
+ }); // (password: string) => Result<string, Failure<"WEAK_PASSWORD">>
164
+
165
+ const result = validatePassword("secret"); // Result<string, Failure<"WEAK_PASSWORD">>
166
+ ```
167
+
168
+ ### Generator Syntax
169
+
170
+ Generators let you write error-handling code that reads like regular code,
171
+ automatically short-circuiting on failures.
172
+
173
+ ```typescript
174
+ const checkPassword = fn((password: string, hash: string) => {
175
+ const isValid = password === hash; // Simplified
176
+ if (!isValid) {
177
+ return fail("INVALID_PASSWORD", "Password does not match");
178
+ }
179
+ return ok(true);
180
+ });
181
+
182
+ // Using generators with yield*
183
+ const loginUser = fn(function* (email: string, password: string) {
184
+ // yield* unwraps ok values or short-circuits on fail
185
+ const validatedEmail = yield* validateEmail(email);
186
+ const normalizedEmail = normalizeEmail(validatedEmail);
187
+
188
+ // Async works seamlessly
189
+ const user = yield* findUser(normalizedEmail);
190
+
191
+ // If this fails, the whole function returns the failure
192
+ yield* checkPassword(password, user.passwordHash);
193
+
194
+ return ok(user);
195
+ }); // Fn with dependencies that returns Result<User, Failure<"INVALID_EMAIL"> | ...>
196
+
197
+ const result = await loginUser("user@example.com", "secret123");
198
+ // Result<User, Failure<"INVALID_EMAIL"> | Failure<"USER_NOT_FOUND"> | Failure<"INVALID_PASSWORD">>
199
+
200
+ if (result.isOk()) {
201
+ console.log("Logged in:", result.unwrap());
202
+ } else {
203
+ console.log("Login failed:", result.unwrap().message);
204
+ }
205
+ ```
206
+
207
+ With `yield*`, you unwrap ok values automatically. If any operation fails, the
208
+ entire function short-circuits and returns that failure—no nested `if`
209
+ statements needed.
210
+
211
+ ### Composing Functions
212
+
213
+ Yield other Result-returning functions and their errors propagate automatically.
214
+
215
+ ```typescript
216
+ const createSession = fn(function* (user: User) {
217
+ const sessionId = crypto.randomUUID();
218
+ return ok({ sessionId, userId: user.id });
219
+ });
220
+
221
+ const loginUser = fn(function* (email: string, password: string) {
222
+ const validatedEmail = yield* validateEmail(email);
223
+ const normalizedEmail = yield* ok(normalizeEmail(validatedEmail));
224
+ const user = yield* findUser(normalizedEmail);
225
+
226
+ yield* checkPassword(password, user.passwordHash);
227
+ const session = yield* createSession(user);
228
+
229
+ return ok({ user, session });
230
+ }); // All error types collected: Failure<"INVALID_EMAIL"> | Failure<"USER_NOT_FOUND"> | Failure<"INVALID_PASSWORD">
231
+ ```
232
+
233
+ ### Dependency Injection
234
+
235
+ Declare dependencies without implementing them yet—write complete business logic
236
+ first.
237
+
238
+ ```typescript
239
+ // Declare dependencies (just the types!)
240
+ const Database = fn.dependency<{
241
+ findUser: (email: string) => Promise<User | null>;
242
+ createSession: (userId: string) => Promise<{ sessionId: string }>;
243
+ }>()("database");
244
+
245
+ const Logger = fn.dependency<{
246
+ info: (message: string) => void;
247
+ error: (message: string, error: unknown) => void;
248
+ }>()("logger");
249
+
250
+ // Use dependencies in your code
251
+ const loginUser = fn(function* (email: string, password: string) {
252
+ // Get dependencies with yield* fn.require()
253
+ const db = yield* fn.require(Database);
254
+ const logger = yield* fn.require(Logger);
255
+
256
+ logger.info(`Login attempt for ${email}`);
257
+
258
+ const validatedEmail = yield* validateEmail(email);
259
+ const user = yield* fromThrowable(() => db.findUser(validatedEmail));
260
+
261
+ if (!user) {
262
+ logger.error("User not found", { email });
263
+ return fail("USER_NOT_FOUND", "No user found");
264
+ }
265
+
266
+ yield* checkPassword(password, user.passwordHash);
267
+
268
+ const session = yield* fromThrowable(() => db.createSession(user.id));
269
+
270
+ logger.info(`User ${user.id} logged in`);
271
+
272
+ return ok({ user, session });
273
+ }); // Note: loginUser requires Database and Logger dependencies
274
+ ```
275
+
276
+ The code is complete and type-safe, but we haven't implemented `Database` or
277
+ `Logger` yet.
278
+
279
+ ### Type-Safe Dependency Tracking
280
+
281
+ TypeScript tracks which dependencies are required and prevents calling functions
282
+ until all are provided.
283
+
284
+ ```typescript
285
+ // Compiler error: missing dependencies
286
+ const result = loginUser("user@example.com", "password");
287
+ // ^^^^^^^^^ Type error!
288
+
289
+ // Must inject dependencies first
290
+ const runLogin = loginUser
291
+ .inject(
292
+ Database.impl({
293
+ findUser: async (email) => {/* real implementation */},
294
+ createSession: async (userId) => {/* real implementation */},
295
+ }),
296
+ Logger.impl({
297
+ info: (msg) => console.log(msg),
298
+ error: (msg, err) => console.error(msg, err),
299
+ }),
300
+ ); // runLogin now requires no dependencies
301
+
302
+ // Now callable
303
+ const result = await runLogin("user@example.com", "password");
304
+ ```
305
+
306
+ Partial injection works too:
307
+
308
+ ```typescript
309
+ const partialLogin = loginUser.inject(
310
+ Database.impl({/* ... */}),
311
+ ); // partialLogin still requires: Logger
312
+
313
+ const fullLogin = partialLogin.inject(
314
+ Logger.impl({/* ... */}),
315
+ ); // fullLogin requires: (none)
316
+
317
+ await fullLogin("user@example.com", "password"); // âś“
318
+ ```
319
+
320
+ ### Different Implementations
321
+
322
+ Swap implementations for testing, development, and production.
323
+
324
+ ```typescript
325
+ // Production
326
+ const productionDb = Database.impl({
327
+ findUser: async (email) => {
328
+ const result = await pool.query("SELECT * FROM users WHERE email = $1", [
329
+ email,
330
+ ]);
331
+ return result.rows[0] || null;
332
+ },
333
+ createSession: async (userId) => {
334
+ const sessionId = crypto.randomUUID();
335
+ await pool.query("INSERT INTO sessions (id, user_id) VALUES ($1, $2)", [
336
+ sessionId,
337
+ userId,
338
+ ]);
339
+ return { sessionId };
340
+ },
341
+ });
342
+
343
+ const productionLogger = Logger.impl({
344
+ info: (msg) => winston.info(msg),
345
+ error: (msg, err) => winston.error(msg, { error: err }),
346
+ });
347
+
348
+ // Testing (mocks)
349
+ const testDb = Database.impl({
350
+ findUser: async (email) => {
351
+ if (email === "test@example.com") {
352
+ return { id: "test-123", email, passwordHash: "hashed" };
353
+ }
354
+ return null;
355
+ },
356
+ createSession: async () => ({ sessionId: "test-session" }),
357
+ });
358
+
359
+ const testLogger = Logger.impl({
360
+ info: () => {}, // Silent in tests
361
+ error: () => {},
362
+ });
363
+
364
+ // Use in production
365
+ const prodLogin = loginUser.inject(productionDb, productionLogger);
366
+
367
+ // Use in tests
368
+ const testLogin = loginUser.inject(testDb, testLogger);
369
+
370
+ // Same code, different implementations
371
+ ```
372
+
373
+ ### Nested Dependencies
374
+
375
+ Functions can call other functions with dependencies, and those dependencies
376
+ propagate automatically.
377
+
378
+ ```typescript
379
+ const RateLimiter = fn.dependency<{
380
+ checkLimit: (email: string) => Promise<boolean>;
381
+ }>()("rateLimiter");
382
+
383
+ // This function has its own dependency
384
+ const checkRateLimit = fn(function* (email: string) {
385
+ const limiter = yield* fn.require(RateLimiter);
386
+ const allowed = yield* fromThrowable(() => limiter.checkLimit(email));
387
+
388
+ if (!allowed) {
389
+ return fail("RATE_LIMITED", "Too many login attempts");
390
+ }
391
+
392
+ return ok(true);
393
+ }); // Requires: RateLimiter
394
+
395
+ // Call functions with dependencies using .yield()
396
+ const loginUser = fn(function* (email: string, password: string) {
397
+ const db = yield* fn.require(Database);
398
+ const logger = yield* fn.require(Logger);
399
+
400
+ yield* checkRateLimit.yield(email);
401
+
402
+ // ... rest of login logic
403
+
404
+ return ok({ user, session });
405
+ }); // Requires: Database, Logger, RateLimiter (inherited from checkRateLimit)
406
+
407
+ const runLogin = loginUser.inject(
408
+ Database.impl({/* ... */}),
409
+ Logger.impl({/* ... */}),
410
+ RateLimiter.impl({/* ... */}), // Required!
411
+ );
412
+ ```
413
+
414
+ Use `.yield()` to call functions with dependencies. Dependencies automatically
415
+ propagate to parent functions and are tracked in the type system.
416
+
417
+ ## Testing
418
+
419
+ Inject mocks without any mocking libraries.
420
+
421
+ ```typescript
422
+ import { test, expect } from "bun:test";
423
+
424
+ test("loginUser - successful login", async () => {
425
+ const mockDb = Database.impl({
426
+ findUser: async () => ({
427
+ id: "123",
428
+ email: "test@example.com",
429
+ passwordHash: "hash",
430
+ }),
431
+ createSession: async () => ({ sessionId: "session-123" }),
432
+ });
433
+
434
+ const mockLogger = Logger.impl({ info: () => {}, error: () => {} });
435
+
436
+ const login = loginUser.inject(mockDb, mockLogger);
437
+ const result = await login("test@example.com", "password");
438
+
439
+ expect(result.isOk()).toBe(true);
440
+ });
441
+
442
+ test("loginUser - user not found", async () => {
443
+ const mockDb = Database.impl({
444
+ findUser: async () => null,
445
+ createSession: async () => ({ sessionId: "session-123" }),
446
+ });
447
+
448
+ const mockLogger = Logger.impl({ info: () => {}, error: () => {} });
449
+
450
+ const login = loginUser.inject(mockDb, mockLogger);
451
+ const result = await login("test@example.com", "password");
452
+
453
+ expect(result.hasFailed()).toBe(true);
454
+ if (result.hasFailed()) {
455
+ expect(result.unwrap().code).toBe("USER_NOT_FOUND");
456
+ }
457
+ });
458
+ ```
459
+
460
+ ## API Reference
461
+
462
+ ### Core Functions
463
+
464
+ ```typescript
465
+ ok(value) // Create ok result
466
+ fail(code, message, cause?) // Create failed result
467
+ fromThrowable(fn, mapper?) // Convert throwing code to Result
468
+ wrapThrowable(fn, mapper?) // Wrap function to return Results
469
+
470
+ result.map(fn) // Transform ok value
471
+ result.mapFailure(fn) // Transform failure
472
+ result.mapFailure({ CODE: handler }) // Handle specific error codes
473
+ result.match({ ok, failed }) // Pattern match both cases
474
+ result.unwrap() // Get value or failure
475
+ result.unwrapOr(default) // Get value or default
476
+ result.isOk() // Check if ok
477
+ result.hasFailed() // Check if failed
478
+ ```
479
+
480
+ ### Generator Functions
481
+
482
+ ```typescript
483
+ const myFn = fn(function* (arg) {
484
+ const value = yield* someResult; // Unwrap or short-circuit
485
+ return ok(result);
486
+ });
487
+
488
+ myFn(arg); // Call (if no dependencies)
489
+ myFn.run(arg); // Explicit run
490
+ ```
491
+
492
+ ### Dependency Injection
493
+
494
+ ```typescript
495
+ // Declare
496
+ const Service = fn.dependency<Type>()("key");
497
+
498
+ // Require
499
+ const service = yield * fn.require(Service);
500
+
501
+ // Implement
502
+ const impl = Service.impl({/* implementation */});
503
+
504
+ // Inject
505
+ const runnable = myFn.inject(impl1, impl2);
506
+
507
+ // Compose
508
+ yield * childFn.yield(args);
509
+ ```
510
+
511
+ ## License
512
+
513
+ MIT
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "skyr",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "module": "src/index.ts",
6
+ "files": ["src/*", "README.md", "package.json"],
7
+ "scripts": {
8
+ "test": "bun test",
9
+ "check": "bun build ./src/index.ts --target=node --outfile=/dev/null",
10
+ "lint": "biome check .",
11
+ "format": "biome format --write .",
12
+ "format:check": "biome format ."
13
+ },
14
+ "devDependencies": {
15
+ "@biomejs/biome": "^1.9.4",
16
+ "@types/bun": "latest",
17
+ "typescript": "^5.9.3"
18
+ }
19
+ }