A dependency-aware task resolver using RxJS observables for asynchronous execution. This library allows you to define tasks with dependencies and automatically resolves them in the correct order.
- Dependency Resolution: Automatically determines the execution order based on task dependencies
- RxJS Integration: Built on RxJS observables for powerful async handling
- Type Safety: Full TypeScript support with type inference
- Error Handling: Built-in error handling with graceful failure modes
npm install @robinw151/resolver
# or
pnpm add @robinw151/resolver
# or
yarn add @robinw151/resolverimport{lastValueFrom}from'rxjs';import{Resolver,isSuccess}from'@robinw151/resolver';// Create a resolver instanceconstresolver=newResolver()// Register task A with no dependencies.register({id: 'A',fn: ()=>'Hello',})// Register task B with no dependencies.register({id: 'B',fn: ()=>'World',})// Register task C that depends on A and B.register({id: 'C',fn: ({ A, B })=>{if(isSuccess(A)&&isSuccess(B)){return`${A.data}${B.data}!`;}thrownewError('Missing dependencies');},},['A','B'],);// Resolve all tasksconstresult=awaitlastValueFrom(resolver.resolve());console.log(result);// { tasks: { A: { data: 'Hello' }, B: { data: 'World' }, C: { data: 'Hello World!' } }}import{lastValueFrom}from'rxjs';import{Resolver,isSuccess,isError,hasNoErrors}from'@robinw151/resolver';constresolver=newResolver().register({id: 'fetchUser',fn: ()=>({id: 1,name: 'John'}),}).register({id: 'fetchPosts',fn: ({ fetchUser })=>{if(isSuccess(fetchUser)){return[{id: 1,title: 'Post 1'},{id: 2,title: 'Post 2'},];}thrownewError('User not found');},},['fetchUser'],).register({id: 'generateReport',fn: ({ fetchUser, fetchPosts })=>{if(isSuccess(fetchUser)&&isSuccess(fetchPosts)){return{user: fetchUser.data,postCount: fetchPosts.data.length,timestamp: newDate().toISOString(),};}thrownewError('Missing data for report');},},['fetchUser','fetchPosts'],);try{constresult=awaitlastValueFrom(resolver.resolve());if(isError(result.tasks.fetchUser)){console.error('User fetch failed:',result.tasks.fetchUser.error);}if(isError(result.tasks.fetchPosts)){console.error('Posts fetch failed:',result.tasks.fetchPosts.error);}if(isError(result.tasks.generateReport)){console.error('Report generation failed:',result.tasks.generateReport.error);}if(hasNoErrors(result.tasks)){console.log('Report generated:',result.tasks.generateReport.data);}}catch(error){console.error('Resolution failed:',error);}The resolver provides full TypeScript support with type inference:
import{Resolver,isSuccess,isError}from'@robinw151/resolver';// Task result types are inferred from the registered task functionsconstresolver=newResolver().register({id: 'user',fn: ()=>({id: 1,name: 'John'}),}).register({id: 'posts',fn: ({ user })=>{// user is typed as { data: { id: number; name: string }} | { error: unknown }if(isSuccess(user)){console.log('User loaded:',user.data.name);return[{id: 1,title: 'Post 1'}];}else{console.error('User failed to load:',user.error);return[];}},},['user'],);Every task produces exactly one result. A task function may return a plain value, a Promise or an Observable:
import{of}from'rxjs';constresolver=newResolver().register({id: 'value',fn: ()=>1}).register({id: 'promise',fn: ()=>Promise.resolve(2)}).register({id: 'observable',fn: ()=>of(3)});For an Observable the first emitted value becomes the task's result. The subscription is closed right after that value, so later emissions are never observed, and sources that honor unsubscription are cancelled:
// Only the first value is used, the subscription is closed afterwards
fn: ()=>of(1,2,3);// { data: 1 }// Observed as events, an HttpClient request emits `HttpEventType.Sent` first,// so the task resolves with that event and the request is cancelled
fn: ()=>http.get('/user',{observe: 'events',reportProgress: true});Pipe the source when a different value is needed:
import{last,toArray}from'rxjs';
fn: ()=>of(1,2,3).pipe(last());// { data: 3 }
fn: ()=>of(1,2,3).pipe(toArray());// { data: [1, 2, 3] }Keep in mind that last() and toArray() only emit once the source completes. Applying either to a source that never completes leaves the task, and therefore the whole resolution, pending indefinitely. The default behavior has no such risk, which is why an infinite source such as interval(1000) resolves with its first value instead of hanging.
A source that completes without emitting any value cannot produce a result. Such a task resolves with an EmptyTaskError instead of blocking the resolution:
import{EMPTY,lastValueFrom}from'rxjs';import{EmptyTaskError,isError,Resolver}from'@robinw151/resolver';constresult=awaitlastValueFrom(newResolver().register({id: 'empty',fn: ()=>EMPTY}).resolve());if(isError(result.tasks.empty)){console.log(result.tasks.empty.errorinstanceofEmptyTaskError);// true}Tasks can return either successful data or errors. The resolver handles both cases gracefully:
- Successful tasks return
{ data: TResult } - Failed tasks return
{ error: unknown }
A task whose Observable completes without emitting a value fails with an EmptyTaskError, which is exported from the package and carries the taskId of the task that produced it.
The resolver supports global arguments that are passed to all task functions during execution. This is useful for sharing configuration, API keys, or other context across all tasks.
You can provide global arguments when creating a resolver instance:
import{lastValueFrom}from'rxjs';import{Resolver}from'@robinw151/resolver';// Create resolver with global argumentsconstresolver=newResolver({apiKey: 'your-api-key',baseUrl: 'https://api.example.com'}).register({id: 'fetchUser',fn: (_args,globalArgs)=>{// globalArgs is typed as { apiKey: string; baseUrl: string }returnfetch(`${globalArgs.baseUrl}/user`,{headers: {Authorization: `Bearer ${globalArgs.apiKey}`},});},}).register({id: 'fetchPosts',fn: (_args,globalArgs)=>{returnfetch(`${globalArgs.baseUrl}/posts`,{headers: {Authorization: `Bearer ${globalArgs.apiKey}`},});},});constresult=awaitlastValueFrom(resolver.resolve());console.log(result.globalArgs);// { apiKey: 'your-api-key', baseUrl: 'https://api.example.com' }Because tasks receive the global arguments typed as TGlobalArgs, the constructor argument is required whenever TGlobalArgs cannot be undefined:
newResolver();// OK - no global arguments at allnewResolver({apiKey: 'your-api-key'});// OK - type is inferrednewResolver<{apiKey: string}>({apiKey: 'your-api-key'});// OK - explicit type, value providednewResolver<{apiKey: string}>();// Error - tasks would receive `undefined`If the arguments are only known later and are supplied through setGlobalArgs() or resolve({ globalArgs }), include undefined in the type. Task functions then have to narrow it before use:
constresolver=newResolver<{apiKey: string}|undefined>().register({id: 'fetchUser',fn: (_args,globalArgs)=>{if(!globalArgs){thrownewError('Global arguments have not been set');}returnfetch('/user',{headers: {Authorization: `Bearer ${globalArgs.apiKey}`}});},});resolver.setGlobalArgs({apiKey: 'your-api-key'});You can update global arguments after creating the resolver using setGlobalArgs():
constresolver=newResolver({version: 'v1'}).register({id: 'getVersion',fn: (_args,globalArgs)=>globalArgs.version,});// First resolutionconstresult1=awaitlastValueFrom(resolver.resolve());console.log(result1.globalArgs.version);// 'v1'// Update global argumentsresolver.setGlobalArgs({version: 'v2'});// Second resolution with updated argumentsconstresult2=awaitlastValueFrom(resolver.resolve());console.log(result2.globalArgs.version);// 'v2'The resolve() method accepts an optional options parameter to control its behavior:
interfaceResolveOptions{globalArgs?: TGlobalArgs;withLoadingState?: boolean;}Provides a temporary override for global arguments passed to all tasks during this specific resolution. This does not mutate the instance's globalArgs and only affects this resolution call.
- Purpose: Allows different global arguments for specific resolutions without changing the resolver instance
- Behavior: Overrides the instance's globalArgs for this resolution only
- Type: Same type as the resolver's global arguments (
TGlobalArgs)
constresolver=newResolver({apiKey: 'default-key',baseUrl: 'https://api.example.com'}).register({id: 'fetchData',fn: (_args,globalArgs)=>{returnfetch(`${globalArgs.baseUrl}/data`,{headers: {Authorization: `Bearer ${globalArgs.apiKey}`},});},});// Use temporary global args for this resolutionconstresult=awaitlastValueFrom(resolver.resolve({globalArgs: {apiKey: 'temp-key',baseUrl: 'https://temp.api.com'},}),);console.log(result.globalArgs);// { apiKey: 'temp-key', baseUrl: 'https://temp.api.com' }// Next resolution uses the original instance globalArgsconstresult2=awaitlastValueFrom(resolver.resolve());console.log(result2.globalArgs);// { apiKey: 'default-key', baseUrl: 'https://api.example.com' }Controls whether the resolver emits a loading state as the first value in the observable stream.
false(default): Only emits the final result without the loading statetrue: Emits{ loading: true }as the first value, followed by the final result
import{isLoading}from'@robinw151/resolver';// Without loading state (default behavior)resolver.resolve().subscribe((result)=>{console.log('Final result:',result);});// With loading stateresolver.resolve({withLoadingState: true}).subscribe((result)=>{if(isLoading(result)){console.log('Resolution in progress...');}else{console.log('Final result:',result);}});The resolver provides several utility functions to help you work with task results and resolver states:
Type guard that checks if a task result contains successful data.
import{isSuccess}from'@robinw151/resolver';if(isSuccess(taskResult)){// taskResult is typed as { data: TValue }console.log(taskResult.data);}Type guard that checks if a task result contains an error.
import{isError}from'@robinw151/resolver';if(isError(taskResult)){// taskResult is typed as { error: unknown }console.error(taskResult.error);}Type guard that checks if a resolver result is in a loading state.
import{isLoading}from'@robinw151/resolver';if(isLoading(resolverResult)){console.log('Resolution is still in progress...');}Type guard that checks if all task results in a resolver result contain successful data (no errors). This function performs a runtime check to determine if every task in the result object has completed successfully.
import{hasNoErrors}from'@robinw151/resolver';constresult=awaitlastValueFrom(resolver.resolve());if(hasNoErrors(result.tasks)){// All tasks succeeded - safe to access dataconsole.log('User:',result.tasks.user.data.name);console.log('Posts count:',result.tasks.posts.data.length);}else{// Some tasks failed - handle errors appropriatelyconsole.log('Some tasks failed during resolution');}