Last active
November 21, 2024 16:06
-
-
Save monotykamary/b86188f0dde8e9de1074916722c11e07 to your computer and use it in GitHub Desktop.
Elixir Protocols in TypeScript - Type Safest Version
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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