Typed Responses with Discriminated Union
Callback messages return a single typed value. When the handler can face different outcomes (success, not-found, permission denied, validation error), modelling the response as a discriminated union gives the sender a full, type‑safe picture of every possible result.
1. Designing the response type
A discriminated union is a type that uses a common status (or kind) field to tell the variants apart.
type ProductResult =
| { status: 'success'; data: Product }
| { status: 'not-found' }
| { status: 'unavailable'; reason: string }
| { status: 'error'; code: string };
This type documents every outcome the sender must handle. There is no null with ambiguous meaning.
2. Defining the callback message
Use the union as the generic parameter of PostboyCallbackMessage.
import {PostboyCallbackMessage} from '@artstesh/postboy';
export class GetProductQuery extends PostboyCallbackMessage<ProductResult> {
static readonly ID = 'product.get';
constructor(public readonly productId: string) {
super();
}
}
3. Handler: return the correct variant
The handler constructs the appropriate variant based on the real outcome.
import {ConnectMessage} from '@artstesh/postboy';
import {Subject} from 'rxjs';
import {GetProductQuery} from './messages/queries/get-product.query';
export class ProductService {
constructor(private http: HttpClient, private postboy: AppPostboyService) {
this.postboy.exec(new ConnectMessage(GetProductQuery, new Subject<GetProductQuery>()));
this.postboy.sub(GetProductQuery).subscribe((msg) => {
this.http.get<Product>('/api/products/' + msg.productId).subscribe({
next: (product) => {
if (!product.available) {
msg.finish({status: 'unavailable', reason: product.restockDate});
} else {
msg.finish({status: 'success', data: product});
}
},
error: (err) => {
if (err.status === 404) {
msg.finish({status: 'not-found'});
} else {
msg.finish({status: 'error', code: err.message});
}
},
});
});
}
}
4. Sender: exhaustive handling
The sender uses a switch statement (or if/else chain) that TypeScript can verify as exhaustive when the strictNullChecks and noImplicitReturns options are enabled.
import {Component} from '@angular/core';
import {GetProductQuery} from './messages/queries/get-product.query';
@Component({ /* ... */})
export class ProductViewComponent {
constructor(private postboy: AppPostboyService) {
}
loadProduct(id: string): void {
this.postboy.fireCallback(new GetProductQuery(id)).subscribe((result) => {
switch (result.status) {
case 'success':
this.showProduct(result.data);
break;
case 'not-found':
this.showMessage('Product not found');
break;
case 'unavailable':
this.showMessage('Product will be available ' + result.reason);
break;
case 'error':
this.showError(result.code);
break;
// TypeScript will warn if a variant is missing
}
});
}
private showProduct(product: Product) { /* ... */
}
private showMessage(text: string) { /* ... */
}
private showError(code: string) { /* ... */
}
}
5. Extended example: loading states
A discriminated union can also represent UI loading states, combining progress with the final data.
type OrderHistoryResult =
| { state: 'loading' }
| { state: 'loaded'; orders: Order[] }
| { state: 'empty' }
| { state: 'error'; message: string };
export class GetOrderHistoryQuery extends PostboyCallbackMessage<OrderHistoryResult> {
static readonly ID = 'order.history';
constructor(public readonly customerId: string) {
super();
}
}
The handler can immediately return { state: 'loading' } if the operation starts asynchronously (applicable when using streaming), or just return the final state. The sender can then render a spinner until state transitions to loaded, empty, or error.
Key points
A discriminated union makes every outcome explicit. The sender code cannot accidentally ignore a variant.
The handler is responsible for mapping application results into the correct variant.
TypeScript’s control flow analysis helps detect unhandled cases in switch statements.
Using unions reduces reliance on null and undefined, making the callback contract self‑documenting.
24 апреля 2026