Upgrade to Pro — share decks privately, control downloads, hide ads and more …

TypeScript 関数型スタイルでバックエンド開発のリアル

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.

TypeScript 関数型スタイルでバックエンド開発のリアル

TSKaigi 2024 のスライドです

Avatar for Naoya Ito

Naoya Ito

May 10, 2024
Tweet

More Decks by Naoya Ito

Other Decks in Programming

Transcript

  1. ͳ͓ɺॻ੶ͦͷ௨Γʹ͸΍͍ͬͯͳ͍ • υϝΠϯΦϒδΣΫτΛܕͰදݱ͢Δ ˠ͍͍Ͷ • ΤϥʔʹΑΔ෼ذ͸ 3FTVMUܕͰѻ͍ɺΤϥʔॲཧΛ໌ࣔతʹ͢Δ ˠ ͍͍Ͷ •

    XPSLGMPXʹ௚઀υϝΠϯΦϒδΣΫτΛఆٛ͢Δ τϥϯβΫγϣϯεΫϦϓτ  3FQPTJUPSZύλʔϯ͸ཁΒͳ͍ ˠͳͥ 🤔 ࢀߟʹ͍ͯ͠ͳ͍
  2. 5ZQF4DSJQUͰɺͲΜͳελΠϧͰ࣮૷͍ͯ͠Δ͔ • υϝΠϯΦϒδΣΫτ͸ܕͰߏ଄Λఆٛ͢Δ – DMBTTͰ͸ͳ͘ JOUFSGBDF • ΦϒδΣΫτͷมߋ͸ʮؔ਺ద༻ʹΑΔঢ়ଶભҠʯͱͯ͠Πϛϡʔλϒϧʹ͢Δ – ʮΦϒδΣΫτͷ಺෦ঢ়ଶͷॻ͖׵໋͑ྩʯͰ͸ͳ͘ʮؔ਺ద༻

    ࣸ૾ ʹΑΓঢ়ଶΛҠ͢ʯ • ۩ମతͳϢʔεέʔε͸ XPSLGMPXͱ࣮ͯ͠૷ – ೖྗ஋ͱυϝΠϯΦϒδΣΫτ͕υϝΠϯΠϕϯτʹ൐͍৽͍͠ঢ়ଶʹભҠɻͦͷաఔΛܕͰఆٛ – ঢ়ଶભҠʹ͸ࣦഊ Τϥʔ ͕൐͏ɻ໭Γೳ͍Λ 3FTVMUʹ͠ʮ3FTVMUΛฦؔ͢਺ͷ߹੒ʯͰϑϩʔΛߏ੒ – 3FTVMU͸ඪ४ʹͳ͍ͷͰ OFWFSUISPXΛར༻ ಥ͖٧ΊΔͱɺ͕ؔ͜͜ ਺ܕϓϩάϥϛϯά
  3. υϝΠϯΦϒδΣΫτ͸ܕͰఆٛ͢Δ export interface Customer { id: CustomerId groupId: RestaurantGroupId name:

    CustomerName nameKana: CustomerNameKana | null nickname: CustomerNickname | null gender: Gender smoking: SmokingType memo: CustomerMemo | null archived: boolean phoneNumbers: CustomerPhoneNumber[] workplaces: CustomerWorkplace[] restaurantContexts: CustomerRestaurantContext[] emails: CustomerEmail[] events: CustomerEvent[] tagIds: TagId[] allergyIds: AllergyId[] }
  4. 6OJPOܕΛੵۃతʹར༻͢Δ export type Reservation = DirectReservation | SiteReservation interface _Reservation

    { id: ReservationId restaurantId: RestaurantId type: ReservationType events: ReservationEvent[] customerIds: CustomerId[] confirmStatus: Confirmed | Unconfirmed operationStatus: BuiltInOperationStatus | CustomOperationStatus orders: Order[] } export interface DirectReservation extends _Reservation { type: 'Direct' events: DirectReservationEvent[] } export interface SiteReservation extends _Reservation { type: 'Site' events: ReservationEvent[] }
  5. ஋ΦϒδΣΫτ // Nominal な型 export type CustomerId = newtype<'CustomerId', string>

    declare const __newtype: unique symbol; export type newtype<Constructor, Type> = Type & { readonly [__newtype]: Constructor; };
  6. ஋ΦϒδΣΫτ͸ίϯετϥΫλͰੜ੒ ࣦഊ͸ 3FTVMUͰฦ͢ export type Color = newtype<'Color', string> export

    function Color(value: string): Result<Color, ValidationError> { return /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(value) ? ok(value as Color) : err(new ValidationError('色の値が不正です。#FFFFFF形式で指定してください')) }
  7. customer.archive() const archived = archiveCustomer(customer) • ໋ྩతʹॻ͘ – ΦϒδΣΫτͷ಺෦ঢ়ଶΛॻ͖׵͑Δ໋ྩΛߦ͏͜ͱͰɺঢ়ଶΛมԽͤ͞Δ –

    ঢ়ଶͷมԽ͕҉໧త uuu໭Γ஋͕ͳ͍ • ؔ਺తʹॻ͘ – Ҿ਺ͷΦϒδΣΫτʹؔ਺Λద༻ͯ͠ɺঢ়ଶભҠޙͷΦϒδΣΫτΛಘΔ – ঢ়ଶͷมԽ͕໌ࣔత uuu ભҠલͷঢ়ଶ͸ඞͣҾ਺ʹݱΕɺભҠޙͷঢ়ଶ͸໭Γ஋ʹݱΕΔ
  8. ϫʔΫϑϩʔͷͱ͋Δαϒεςοϓ uuu ࣦഊͱͷ෼ذ͸ 3FTVMUͰදݱ // 予約リクエストから来店者情報を抽出 type extractActualVisitor = (command:

    SiteReservationValidated) => Result<VisitorExtracted, ValidationError> const extractActualVisitor: extractActualVisitor = (command) => ok(command.input.reservationEvent.siteReservation.guest) .andThen(ReservationGuest) .map(choiceActualVisitor) .map((visitor) => ({ ...command, kind: 'VisitorExtracted' as const, visitor: visitor, }))
  9. ϫʔΫϑϩʔʹ͓͚Δঢ়ଶભҠΛܕͰఆٛ͢Δ interface UnvalidatedCommand { kind: 'Unvalidated' input: { groupId: RestaurantGroupId

    reservationEvent: UnvalidatedSiteReservationEvent } } interface SiteReservationValidated { kind: 'SiteReservationValidated' input: { groupId: RestaurantGroupId reservationEvent: ValidatedSiteReservationEvent } } interface VisitorExtracted { kind: 'VisitorExtracted' input: { groupId: RestaurantGroupId reservationEvent: ValidatedSiteReservationEvent } visitor: Visitor | ReservationHolder } interface CustomerNotIdentified { kind: 'CustomerNotIdentified' input: { groupId: RestaurantGroupId visitor: Visitor | ReservationHolder customer: ValidatedCustomer reservationEvent: ValidatedSiteReservationEvent } } interface CustomerIdentified { kind: 'CustomerIdentified' input: { groupId: RestaurantGroupId visitor: Visitor | ReservationHolder customer: IdentifiedCustomer reservationEvent: ValidatedSiteReservationEvent } } ... interface SiteReservationEventImported { kind: 'SiteReservationEventImported' reservation: CreatedReservation customer: CreatedCustomer | UnchangedCustomer | UpdatedCustomer }
  10. Ұ࿈ͷαϒεςοϓΛ߹੒͠ϫʔΫϑϩʔ Ϣʔεέʔε Λߏ੒͢Δ type WorkFlow = (command: UnvalidatedCommand) => ResultAsync<ImportSiteReservationEvent[],

    ImportSiteReservationError> export const importReservationEventWorkFlow = ( findIdenticalCustomer: findIdenticalCustomer, // ドメンサービスを高階関数としてDI findReservationSlotsByTableAllocations: findReservationSlotsByTableAllocations, findBestTablesForAssignedTable: findBestTablesForAssignedTable ): WorkFlow => (command) => ok(command) .andThen(validateReservationEvent) // バリデーション .andThen(extractActualVisitor) // 来店者情報の抽出 .asyncAndThen(identifyCustomer(findIdenticalCustomer)) // 名寄せ .andThen((command) => { switch (command.kind) { case 'CustomerIdentified': return updateCustomer(command) // 顧客の更新 case 'CustomerNotIdentified': return createNewCustomer(command) // 新規顧客の作成 } }) .andThen(importReservationEvent(findBestTablesForAssignedTable, isOverlappingWithOtherReservations) .andThen(findAffectedReservationSlots(findReservationSlotsByTableAllocations)
  11. ϫʔΫϑϩʔΛ (SBQI2-NVUBUJPO͔Βݺͼ *0ͰαϯυΠον͢Δ builder.mutationField('updateTablePattern', (t) => t.field({ type: UpdateTablePatternPayload, args:

    { patternId: t.arg({ type: 'UUID', description: 'テーブルパターンID' }), input: t.arg({ type: TablePatternInput }), }, authScopes: (_parent, { patternId }, context) => canUpdateTablePattern(context, patternId), resolve: (_parent, { patternId, input }, context) => { // ドメインサービスを DI してワークフローを作る const workflow = updateTablePatternWorkFlow(checkTablePatternExists(context)) const preprocess = ok(patternId) .andThen(TablePatternId) .asyncAndThen(getTablePatternById(context)) .map((pattern) => toUnvalidatedCommand({ input, pattern })) const result = preprocess.andThen(workflow).andThen(updateTablePattern(context)) return result.match( (pattern) => ({ pattern }), (error) => { throw error }, ) }, }), ) σʔλऔಘ σʔλอଘ ߋ৽
  12. σʔλϕʔε *0͸ 3FQPTJUPSZύλʔϯ ͨͩͨͩ͠ͷؔ਺  export const findTagById = ({

    prisma }: applicationContext) => (id: TagId): ResultAsync<Tag | null, ValidationError | PrismaClientError> => ResultAsync.fromPromise( prisma.tag.findUnique({ where: { id } }), PrismaClientError, ).andThen((tag) => (tag ? Tag(tag) : ok(null))) export const getTagById = (context: applicationContext) => (id: TagId): ResultAsync<Tag, EntityNotFound | ValidationError | PrismaClientError> => findTagById(context)(id).andThen((tag) => tag ? ok(tag) : err(new EntityNotFound(`タグがみつかりません: ${id}`)), ) export const createTag = ({ prisma }: applicationContext) => (model: CreatedTag): ResultAsync<TagData, PrismaClientError> => { const { kind: _, ...tag } = model const icon = toIconData(tag.icon) return ResultAsync.fromPromise( prisma.tag.create({ data: { ...tag, ...icon, }, }), PrismaClientError, ) }
  13. *0ˠυϝΠϯ૚ υϝΠϯΦϒδΣΫτͷঢ়ଶભҠ ˠ*0 • Ϣʔβʔ͔Βͷೖྗɺσʔλϕʔε͔ΒͷΦϒδΣΫτͷऔಘɺอଘΛ੍ޚ͢Δͷ͸ (SBQI2- NVUBUJPO • σʔλϕʔε͔ΒͷΦϒδΣΫτͷऔಘɺอଘ͸ 3FQPTJUPSZuuu

    ͕͜͜ *0ڥք • ೖྗͱυϝΠϯΦϒδΣΫτΛʮίϚϯυΦϒδΣΫτʯʹ·ͱΊͯʮϫʔΫϑϩʔʯʹྲྀ͠ࠐΉɻ͜ ͔͜Βઌ͕ɺυϝΠϯϨΠϠʔ • ϫʔΫϑϩʔ͔Βग़͖ͯͨυϝΠϯΠϕϯτ యܕతʹ͸ঢ়ଶભҠࡁΈͷυϝΠϯΦϒδΣΫτͷอଘ  Λݩʹग़ྗͷ *0Λ࣮ߦ
  14. γεςϜશମͷ࣮૷͸ɺैདྷͷ࣮૷͔ΒͦΕ΄ͲมΘΒͳ͍ • ΞϓϦέʔγϣϯશମ͸͜Ε·Ͱ௨ΓͷΦχΦϯΞʔΩςΫνϟʔ • υϝΠϯϨΠϠʔ͸ؔ਺ܕͱ͍ͬͯ΋ϙΠϯτ͸ओʹͭͷΈ – ΦϒδΣΫτͷߏ଄Λܕ JOUFSGBDF Ͱදݱ͢Δ –

    ΦϒδΣΫτͷมߋΛɺؔ਺ద༻ ঢ়ଶભҠͰΠϛϡʔλϒϧ໌ࣔతʹදݱ͢Δ – ࣦഊͷ෼ذ Τϥʔ ͸ 3FTVMUͰදݱ͢Δ • ΦϒδΣΫτࢦ޲Ͱ΍͍ͬͯͨͱ͖ͱɺγεςϜΛ੔ཧ͢Δߟ͑ํมΘͬͯͳ͍ – υϝΠϯϞσϦϯάΛͯ͠ɺू໿ɺΤϯςΟςΟɺ஋ΦϒδΣΫτʹ੔ཧ͠ߏ଄Խ͢Δ – ΦϒδΣΫτͷܕఆٛͱಉ͡ϑΝΠϧɺۙ͘ʹͦͷΦϒδΣΫτʹద༻͢Δؔ਺͕͋Δ
  15. ͳͥυϝΠϯϨΠϠʔΛؔ਺ܕελΠϧʹ͍ͨ͠ͷ͔ • ܕΛ༗ޮʹར༻͍ͨ͠ – ੩తݕࠪʹد͍ͤͯ͘ͱʮಈ͔͞ͳ͍ͱ෼͔Βͳ͍͜ͱʯ͕ݮΔ – υϝΠϯ૚ʹ͓͍ͯʮෆඞཁͳঢ়ଶଘࡏʯΛݮΒ͢ͱݎ࿚ʹͳΔ uuu 6OJPOܕΛੵۃར༻͍ͨ͠ •

    ΦϒδΣΫτͷมߋΛʮؔ਺ద༻ʹΑΔঢ়ଶભҠʯʹ͢ΔͱܕΛهड़͠΍͍͢ – ঢ়ଶભҠલɺޙͷΦϒδΣΫτ͕ؔ਺ͷҾ਺ͱ໭Γ஋ʹදΕΔ • ࣦഊʹΑΔ෼ذ uuu ͭ·ΓΤϥʔॲཧ΋ܕͰѻ͍͍ͨɻ3FTVMUܕ – Τϥʔέʔε͕ᐆດʹͳΔͷ͸ɺۀ຿γεςϜͰ͸ා͍ ͭ·Γɺ໨త͸ʮؔ਺ܕϓϩάϥϛϯάΛ͢Δ͜ͱʯͰ͸ͳ͘ ʮܕɺ੩తݕࠪΛΑΓੵۃతʹར༻͍ͨ͠ʯͱ͜Ζʹ͋Γ·͢
  16. ؔ਺ܕελΠϧΛར༻͍ͨͨ͠Ίͷɺ*0෼཭ uuu ΦχΦϯΞʔΩςΫνϟ • *0ґଘ͕͋Δͱ͔ͦ͜ΒҶͮΔࣜతʹखଓ͖త ໋ྩత ʹͳΓ΍͍͢ • υϝΠϯϨΠϠʔΛ *0ͱ෼཭͢Δ͜ͱͰɺυϝΠϯϨΠϠʔΛؔ਺ܕελΠϧʹ͢Δ༨஍͕ੜ·ΕΔ

    • ͦͷͨΊͷΦχΦϯΞʔΩςΫνϟ – υϝΠϯϩδοΫͷ్தͰ *0͕͋Δ৔߹͸ߴ֊ؔ਺ʹΑΔ %*Λߦ͍ɺϫʔΫϑϩʔࣗମ͸७ਮؔ਺Λҡ࣋͢Δ • ݁ՌɺυϝΠϯΦϒδΣΫτ͸෭࡞༻Λ൐Θͳ͍७ਮͳΦϒδΣΫτ΍ؔ਺ͱͳΓɺܕͰͷදݱ͕༰қ ʹͳΔ
  17. ໘౗ͳͱ͜Ζ uuu 3FTVMUܕύζϧ export const Tag = (input: TagInput): Result<Tag,

    ValidationError> => { const tagId = TagId(input.id) const groupId = RestaurantGroupId(input.groupId) const label = TagLabel(input.label) const icon = input.icon && input.iconType ? TagIcon({ symbol: input.icon, type: input.iconType, color: input.color, }) : ok(NoIcon()) const sortOrder = FractionalIndex(input.sortOrder) const values = Result.combine(tuple(tagId, groupId, label, icon, sortOrder)) return values.map(([id, groupId, label, icon, sortOrder]) => ({ ...input, id, groupId, label, icon, sortOrder, })) } ஋͕͍͍ͩͨ3FTVMUʹೖ͍ͬͯΔ ͨΊɺෳ਺3FTVMU͕͋Δͱ߹੒͠ ͯϑϥοτʹ͢Δඞཁ͕͋Γ໘౗ ͳ࡞ۀʹͳͬͯ͘Δ ݎ࿚Ͱ͸͋Δ
  18. ʮίϯςφʹೖͬͨจ຺͖ͭͷ஋ʯΛѻ͏ɺ૊ΈࠐΈͷػೳ͕͋ΔݴޠͳΒuuu ͜ͷखͷػߏ͕͋Ε͹ೝ஌ෛՙ௿࣮͘૷Ͱ͖Δ͕ɺ࢒೦ͳ͕Β 5ZQF4DSJQUʹ͸ͳ͍ makeTagId :: String -> Either ValidationError TagId

    makeTagId tagId | null tagId = Left (ValidationError "tagId is empty") | otherwise = Right (TagId tagId) makeTag :: String -> String -> Int -> Either ValidationError Tag makeTag id gid order = do tagId <- makeTagId id groupId <- makeGroupId gid sortOrder <- makeSortOder order return (Tag tagId groupId sortOrder) )BTLFMM ྫ͑͹Ϟφυʹ͸ɺೖΕࢠʹ ͳͬͨίʔυΛฏୱԽ͢Δޮ༻ ͕͋Δ
  19. ໋ྩͱ 3FTVMU const result = ok(command).asyncAndThen(({ date, restaurantIds }) =>

    { const results = [] for (const restaurantId of restaurantIds) { const task = { restaurantId, date, } const result = ok(task) .asyncAndThen(sender) .mapErr((error) => ({ restaurantId, error })) results.push(result) } return ResultAsync.combineWithAllErrors(results) }) ໋ྩతͳ࣮૷Ͱෳ਺ͷ 3FTVMU ͕བྷΜͰ͘Δͱɺύζϧײ
  20. ໋ྩతʹॻ͍ͨํ͕͍͍ॴ͸खଓ͖Ͱॻ͖ GSPN5ISPXBCMFGSPN1SPNJTFͰแΉ export const sendTask = <T>({ project, queue, location,

    url, serviceAccountEmail }: CloudTasksClientConfig) => (body: T) => { const parent = client.queuePath(project, location, queue) const task: google.cloud.tasks.v2.ITask = { httpRequest: { headers: { 'Content-Type': 'application/json', }, httpMethod: 'POST' as const, url, body: Buffer.from(JSON.stringify(body)).toString('base64'), oidcToken: { serviceAccountEmail, }, }, } return fromPromise( client.createTask({ parent, task }), (error) => new NetworkError(error as string, { cause: error }), ) } ίϯϐϡʔλ΁ͷ໋ྩͳͷ͔ͩ Βɺૉ௚ʹखଓ͖Ͱॻ͚͹ྑ͍
  21. ΍ͬͯΈͯɺ࣮ࡍͲ͏ • ैདྷͷ։ൃΑΓ΋ɺݎ࿚ʹͳͬͨͱࢥ͏ – ӡ༻ظؒ ೥΄Ͳɻෆ۩߹ʹΑΔো֐͕গͳ͍ɻίʔυͷܦ೥ྼԽ΋཈͑ΒΕ͍ͯΔ – ୹ظతͳ։ൃεϐʔυʹ͸ྑ͘΋ѱ͘΋Өڹ͸ͳ͍ ଎͘ͳͬͨɺͱ͔͸ͳ͍ 3FTVMUύζϧ͸໘౗

    • Τϥʔͷॲཧ࿙Ε ૝ఆ͍ͯ͠ͳ͍ঢ়ଶʹΑΔෆ۩߹͕গͳ͍ɻܕ΍ 3FTVMUͰݻΊ͍ͯΔޮ༻ – ӡ༻͍ͯͯͦ͠͏͍͏όά͕ग़Δ͜ͱ͸رɻόά͕ग़Δͷ͸ཁ݅ఆٛࣗମͷؒҧ͍΍ɺ࢓༷ͷߟྀ࿙Ε • Ϣχοτςετ͸ैདྷ։ൃΑΓݮͬͨ – ܕͰอূͰ͖ΔྖҬ͕֦͕ͬͨ͜ͱʹΑΓςετͰͷอূ͕ෆཁʹͳΔ͜ͱ͸ଟ͍ – ΋ͪΖΜ͢΂ͯΛܕͰอূͰ͖ͨΓ͠ͳ͍ͷͰɺϢχοτςετ͸ॻ͘ • ΦϯϘʔσΟϯάେม – υϝΠϯϨΠϠʔͷ࣮૷ʹؔͯ͠ΑΓஸೡͳΦϯϘʔσΟϯά͕ඞཁɻ৽͍͠։ൃऀ͕ࢀը͙ͯ͢͠͸ίʔυϨϏϡʔΛްΊʹͯ͠ ͍Δ – ࠷ॳ͸ 3FTVMUܕΛෳࡶʹ͕ͪ͠ 😂
  22. ͝ࢀߟ uuu ҎલͷൃදεϥΠυͳͲ • 5ZQF4DSJQUʹΑΔ (SBQI2-όοΫΤϯυ։ൃ  4QFBLFS%FDL – IUUQTTQFBLFSEFDLDPNOBPZBUZQFTDSJQUOJZPSVHSBQIRMCBUVLVFOEPLBJGBCEBCBBED

    EFCECE • ؔ਺ܕϓϩάϥϛϯάͱܕγεςϜͷϝϯλϧϞσϧ r 4QFBLFS%FDL – IUUQTTQFBLFSEFDLDPNOBPZBHVBOTIVYJOHQVSPHVSBNJOHVUPYJOHTJTVUFNVOPNFOUBSVNPEFSV • 5ZQF4DSJQUͰͲ͜·Ͱʮؔ਺ܕϓϩάϥϛϯάʯ͢Δ͔ ᴷʮखଓ͖ )BTLFMMʯ͔Βߟ࡯͢Δ  ҰٳDPN %FWFMPQFST#MPH – IUUQTVTFSGJSTUJLZVDPKQFOUSZ