Skip to content

Instantly share code, notes, and snippets.

@monotykamary
Last active November 21, 2024 16:06
Show Gist options
  • Select an option

  • Save monotykamary/b86188f0dde8e9de1074916722c11e07 to your computer and use it in GitHub Desktop.

Select an option

Save monotykamary/b86188f0dde8e9de1074916722c11e07 to your computer and use it in GitHub Desktop.
Elixir Protocols in TypeScript - Type Safest Version
function createDataType<T extends { type: string }>() {
return new Proxy(
{},
{
get(_, type: string) {
return (data = {}) => ({ type, ...data });
},
}
) as {
[K in T['type']]: (
data?: Omit<Extract<T, { type: K }>, 'type'>
) => Extract<T, { type: K }>;
};
}
function createProtocol<
T extends { type: string },
P extends Record<string, (data: T) => any>
>(defaultHandlers: Partial<P> = {}) {
const handlers: Record<T['type'], Partial<P>> = {} as any;
const register = <K extends T['type']>(
type: K,
typeHandlers: Partial<{
[Key in keyof P]: (data: Extract<T, { type: K }>) => ReturnType<P[Key]>;
}>
) => {
handlers[type] = { ...handlers[type], ...typeHandlers };
};
const protocol = {
invoke<K extends keyof P>(method: K, data: T): ReturnType<P[K]> {
// Ensure the type of data.type is valid as a key in handlers
const typeHandlers = handlers[data.type as keyof typeof handlers];
const handler = typeHandlers?.[method] || defaultHandlers[method];
if (!handler) {
throw new Error(
`No implementation found for method "${String(
method
)}" on type "${data.type}".`
);
}
return handler(data as any);
},
};
return { register, protocol };
}
type AnimalType =
| { type: 'Dog'; name: string }
| { type: 'Cat'; name: string }
| { type: 'Bird'; name: string; species: string };
// Create type constructors
const { Dog, Cat, Bird } = createDataType<AnimalType>();
type AnimalProtocol = {
greet: (animal: AnimalType) => string;
speak: (animal: AnimalType) => string;
warn: (animal: AnimalType) => string;
describe: (animal: AnimalType) => string;
}
const { register, protocol: Animal } = createProtocol<AnimalType, AnimalProtocol>({
describe: (animal) => `A ${animal.type} named ${animal.name}`,
});
register('Dog', {
greet: () => 'Woof woof!',
speak: () => 'Woof!',
warn: () => 'Growl',
});
register('Cat', {
greet: () => '...',
speak: () => 'Meow',
warn: () => 'Hiss!',
});
register('Bird', {
greet: () => 'Chirp chirp!',
speak: () => 'Tweet',
warn: () => 'Squawk!',
describe: (animal) => `This is a ${animal.species} bird named ${animal.name}.`,
});
const dog = Dog({ name: 'Buddy' });
const cat = Cat({ name: 'Whiskers' });
const bird = Bird({ name: 'Polly', species: 'Parrot' });
console.log(Animal.invoke('greet', dog)); // Woof woof!
console.log(Animal.invoke('speak', cat)); // Meow
console.log(Animal.invoke('describe', bird)); // This is a Parrot bird named Polly.
console.log(Animal.invoke('describe', dog)); // A Dog named Buddy.
console.log(Animal.invoke('warn', cat)); // Hiss!
type CounterType =
| { type: 'Increment'; amount: number }
| { type: 'Decrement'; amount: number }
| { type: 'Reset'; value: number };
const { Increment, Decrement, Reset } = createDataType<CounterType>();
const { register, protocol: Counter } = createProtocol<
CounterType,
{ apply: (action: CounterType) => number }
>();
register('Increment', {
apply: (action) => action.amount,
});
register('Decrement', {
apply: (action) => -action.amount,
});
register('Reset', {
apply: (action) => action.value,
});
const increment = Increment({ amount: 10 });
const decrement = Decrement({ amount: 5 });
const reset = Reset({ value: 0 });
console.log(Counter.invoke('apply', increment)); // 10
console.log(Counter.invoke('apply', decrement)); // -5
console.log(Counter.invoke('apply', reset)); // 0
// Commands: Intentions from users
type Command =
| { type: 'Deposit'; account: string; amount: number }
| { type: 'Withdraw'; account: string; amount: number }
| { type: 'Transfer'; from: string; to: string; amount: number };
// Events: Results of commands
type Event =
| { type: 'Deposited'; account: string; amount: number }
| { type: 'Withdrawn'; account: string; amount: number }
| { type: 'Transferred'; from: string; to: string; amount: number };
// State: Account balances
type AccountState = Record<string, number>;
// Command Handlers: Convert commands into events
const { register: registerCommandHandler, protocol: CommandHandler } =
createProtocol<
Command,
{
handle: (cmd: Command) => Event[];
}
>();
// Register command handlers
registerCommandHandler('Deposit', {
handle: (cmd) => {
if (cmd.amount <= 0) throw new Error('Amount must be greater than zero.');
return [{ type: 'Deposited', account: cmd.account, amount: cmd.amount }];
},
});
registerCommandHandler('Withdraw', {
handle: (cmd) => {
if (cmd.amount <= 0) throw new Error('Amount must be greater than zero.');
return [{ type: 'Withdrawn', account: cmd.account, amount: cmd.amount }];
},
});
registerCommandHandler('Transfer', {
handle: (cmd) => {
if (cmd.amount <= 0) throw new Error('Amount must be greater than zero.');
if (cmd.from === cmd.to)
throw new Error('Cannot transfer to the same account.');
return [
{ type: 'Withdrawn', account: cmd.from, amount: cmd.amount },
{ type: 'Deposited', account: cmd.to, amount: cmd.amount },
];
},
});
// Event Applier: Modify state based on events
const { register: registerEventHandler, protocol: EventApplier } =
createProtocol<
Event,
{
apply: (state: AccountState, event: Event) => AccountState;
}
>();
// Register event handlers
registerEventHandler('Deposited', {
apply: (state, event) => ({
...state,
[event.account]: (state[event.account] || 0) + event.amount,
}),
});
registerEventHandler('Withdrawn', {
apply: (state, event) => {
if ((state[event.account] || 0) < event.amount)
throw new Error(`Insufficient funds in account ${event.account}`);
return {
...state,
[event.account]: state[event.account] - event.amount,
};
},
});
registerEventHandler('Transferred', {
apply: (state, event) => {
throw new Error('Directly applying Transferred events is not allowed.');
},
});
// Aggregate: Manages state, processes commands, and applies events
class AccountAggregate {
private state: AccountState = {};
constructor(private readonly id: string) {}
// Process commands and generate events
process(command: Command): Event[] {
const events = CommandHandler.invoke('handle', command);
events.forEach((event) => this.apply(event));
return events;
}
// Apply events to state
private apply(event: Event): void {
this.state = EventApplier.invoke('apply', this.state, event);
}
// Get current state
getState(): AccountState {
return this.state;
}
}
// Example usage
const aggregate = new AccountAggregate('aggregate-1');
// Process commands
const depositCmd: Command = { type: 'Deposit', account: '123', amount: 100 };
const withdrawCmd: Command = { type: 'Withdraw', account: '123', amount: 50 };
const transferCmd: Command = {
type: 'Transfer',
from: '123',
to: '456',
amount: 25,
};
console.log('Processing deposit...');
aggregate.process(depositCmd); // [{ type: 'Deposited', account: '123', amount: 100 }]
console.log('Processing withdrawal...');
aggregate.process(withdrawCmd); // [{ type: 'Withdrawn', account: '123', amount: 50 }]
console.log('Processing transfer...');
aggregate.process(transferCmd); // [{ type: 'Withdrawn', ... }, { type: 'Deposited', ... }]
// View final state
console.log('Final state:', aggregate.getState());
// Final state: { '123': 25, '456': 25 }
type FileSystemEntity =
| { type: 'File'; name: string; size: number }
| { type: 'Directory'; name: string; contents: FileSystemEntity[] }
| { type: 'Symlink'; name: string; target: string };
// Create type constructors
const { File, Directory, Symlink } = createDataType<FileSystemEntity>();
// Create protocols
const { register: registerFS, protocol: FileSystem } = createProtocol<
FileSystemEntity,
{
describe: (entity: FileSystemEntity) => string;
size: (entity: FileSystemEntity) => number;
}
>({
describe: (entity) => `Entity: ${entity.type}, name: ${entity.name}`,
size: () => 0,
});
// Register handlers
registerFS('File', {
describe: (file) => `File: ${file.name}, size: ${file.size} bytes`,
size: (file) => file.size,
});
registerFS('Directory', {
describe: (dir) =>
`Directory: ${dir.name}, contains ${dir.contents.length} items`,
size: (dir) =>
dir.contents.reduce((total, item) => total + FileSystem.invoke('size', item), 0),
});
registerFS('Symlink', {
describe: (link) => `Symlink: ${link.name}, points to ${link.target}`,
size: () => 0,
});
// Example usage
const myFile = File({ name: 'report.txt', size: 1024 });
const myLink = Symlink({ name: 'shortcut', target: '/path/to/report.txt' });
const myDir = Directory({ name: 'Documents', contents: [myFile, myLink] });
console.log(FileSystem.invoke('describe', myFile)); // File: report.txt, size: 1024 bytes
console.log(FileSystem.invoke('size', myDir)); // 1024
console.log(FileSystem.invoke('describe', myDir)); // Directory: Documents, contains 2 items
type Command =
| { type: 'Admin'; action: 'ban' | 'unban'; target: string }
| { type: 'Moderator'; action: 'warn' | 'mute'; target: string }
| { type: 'User'; action: 'view'; resource: string };
// Create type constructors
const { Admin, Moderator, User } = createDataType<Command>();
// Create protocol
const { register, protocol: CommandHandler } = createProtocol<
Command,
{
execute: (cmd: Command) => string;
log: (cmd: Command) => void;
}
>({
execute: (cmd) => `Executing ${cmd.action} by default.`,
log: (cmd) => console.log(`Default log: ${cmd.type} executed ${cmd.action}`),
});
// Register handlers
register('Admin', {
execute: (cmd) =>
`Admin ${cmd.action === 'ban' ? 'banned' : 'unbanned'} ${cmd.target}.`,
log: (cmd) =>
console.log(`Admin executed ${cmd.action} on ${cmd.target}.`),
});
register('Moderator', {
execute: (cmd) =>
`Moderator ${cmd.action === 'warn' ? 'warned' : 'muted'} ${cmd.target}.`,
log: (cmd) =>
console.log(`Moderator executed ${cmd.action} on ${cmd.target}.`),
});
register('User', {
execute: (cmd) => `User viewed resource: ${cmd.resource}.`,
});
// Example usage
const adminCmd = Admin({ action: 'ban', target: 'JohnDoe' });
const modCmd = Moderator({ action: 'mute', target: 'JaneDoe' });
const userCmd = User({ action: 'view', resource: 'ProfilePage' });
console.log(CommandHandler.invoke('execute', adminCmd)); // Admin banned JohnDoe.
CommandHandler.invoke('log', modCmd); // Moderator executed mute on JaneDoe.
console.log(CommandHandler.invoke('execute', userCmd)); // User viewed resource: ProfilePage.
CommandHandler.invoke('log', userCmd); // Default log: User executed view
type Transaction =
| { type: 'Deposit'; account: string; amount: number }
| { type: 'Withdrawal'; account: string; amount: number }
| { type: 'Transfer'; from: string; to: string; amount: number };
// Create type constructors
const { Deposit, Withdrawal, Transfer } = createDataType<Transaction>();
// Create protocol
const { register, protocol: TransactionHandler } = createProtocol<
Transaction,
{
process: (tx: Transaction) => string;
validate: (tx: Transaction) => boolean;
}
>({
validate: () => true,
process: (tx) => `Processed ${tx.type} transaction.`,
});
// Register handlers
register('Deposit', {
process: (tx) => `Deposited $${tx.amount} into account ${tx.account}.`,
validate: (tx) => tx.amount > 0,
});
register('Withdrawal', {
process: (tx) => `Withdrew $${tx.amount} from account ${tx.account}.`,
validate: (tx) => tx.amount > 0,
});
register('Transfer', {
process: (tx) =>
`Transferred $${tx.amount} from account ${tx.from} to account ${tx.to}.`,
validate: (tx) => tx.amount > 0 && tx.from !== tx.to,
});
// Example usage
const deposit = Deposit({ account: '12345', amount: 100 });
const withdrawal = Withdrawal({ account: '12345', amount: 50 });
const transfer = Transfer({ from: '12345', to: '67890', amount: 200 });
if (TransactionHandler.invoke('validate', deposit)) {
console.log(TransactionHandler.invoke('process', deposit)); // Deposited $100 into account 12345.
}
if (TransactionHandler.invoke('validate', transfer)) {
console.log(TransactionHandler.invoke('process', transfer)); // Transferred $200 from account 12345 to account 67890.
}
type VehicleType =
| { type: 'Car'; brand: string; model: string; passengers: number }
| { type: 'Bicycle'; brand: string; hasBell: boolean }
| { type: 'Airplane'; airline: string; capacity: number; range: number };
// Create type constructors
const { Car, Bicycle, Airplane } = createDataType<VehicleType>();
// Create protocol
const { register, protocol: Vehicle } = createProtocol<
VehicleType,
{
describe: (vehicle: VehicleType) => string;
move: (vehicle: VehicleType) => string;
safetyCheck: (vehicle: VehicleType) => string;
}
>({
describe: (vehicle) => `A ${vehicle.type}`,
safetyCheck: () => `Default safety check passed.`,
});
// Register handlers
register('Car', {
describe: (car) =>
`Car: ${car.brand} ${car.model}, seats ${car.passengers} passengers.`,
move: () => 'The car drives on the road.',
safetyCheck: (car) =>
`Car Safety Check: ${car.brand} ${car.model} passed inspection.`,
});
register('Bicycle', {
describe: (bicycle) =>
`Bicycle: ${bicycle.brand}, with${bicycle.hasBell ? '' : 'out'} a bell.`,
move: () => 'The bicycle pedals on the bike lane.',
safetyCheck: (bicycle) =>
`Bicycle Safety Check: Bell is ${
bicycle.hasBell ? 'functional' : 'missing'
}.`,
});
register('Airplane', {
describe: (airplane) =>
`Airplane: ${airplane.airline}, capacity ${airplane.capacity}, range ${airplane.range} miles.`,
move: () => 'The airplane soars through the sky.',
safetyCheck: (airplane) =>
`Airplane Safety Check: Capacity of ${airplane.capacity} verified.`,
});
// Example usage
const car = Car({ brand: 'Toyota', model: 'Corolla', passengers: 5 });
const bicycle = Bicycle({ brand: 'Schwinn', hasBell: true });
const airplane = Airplane({
airline: 'Delta',
capacity: 180,
range: 3000,
});
console.log(Vehicle.invoke('describe', car)); // Car: Toyota Corolla, seats 5 passengers.
console.log(Vehicle.invoke('move', bicycle)); // The bicycle pedals on the bike lane.
console.log(Vehicle.invoke('safetyCheck', airplane)); // Airplane Safety Check: Capacity of 180 verified.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment