$30 off During Our Annual Pro Sale. View Details »

TypeScript ⼩ネタとか

SansanTech
PRO
September 12, 2023

TypeScript ⼩ネタとか

■イベント
TypeScriptを活用した型安全なチーム開発
https://sansan.connpass.com/event/292695/

■登壇概要
タイトル:TypeScript ⼩ネタとか
登壇者:技術本部 Digitization部 ⼩⽥ 崇之

■Digitization部 エンジニア 採用情報
https://media.sansan-engineering.com/digitization

SansanTech
PRO

September 12, 2023
Tweet

More Decks by SansanTech

Other Decks in Technology

Transcript

  1. Sansan株式会社
    部署 名前
    TypeScript ⼩ネタとか
    Sansan技術本部
    Sansan技術本部
    技術本部 Digitization部
    ⼩⽥ 崇之

    View Slide

  2. ⼩⽥ 崇之
    Sansan株式会社
    技術本部 Digitization部 Bill One Entry グループ
    中途として2021年にSansanに⼊社。請求書のデータ化システム
    開発に従事。
    今年から役割がマネジャーに変わり、かれこれ半年以上コードか
    ら離れているのに TypeScript の LT やりますとか⼿を挙げてしま
    い、100⼈以上集まったと聞いておっかなびっくりしています。

    View Slide

  3. アジェンダ
    - TypeScript でも Nominal Typing がしたい
    - 型推論に惑わされない; Promise と throw
    - 間話: git alias を晒してみる
    - NestJS でのモジュール設計

    View Slide

  4. TypeScript でも Nominal Typing がしたい

    View Slide

  5. const userRepository = {
    createUser(username: string, password: string) {
    return db.save({ username, password });
    }
    }
    こんなコード書いた事ありませんか?

    View Slide

  6. こんな使われ⽅したら何がおきますか?
    const userService = {
    createUser(username: string, password: string) {
    const hashed = crypto.hash(password);
    return userRepository.createUser(username, password);
    }
    }

    View Slide

  7. こんな使われ⽅したら何がおきますか?
    const userService = {
    createUser(username: string, password: string) {
    const hashed = crypto.hash(password);
    return userRepository.createUser(username, password);
    }
    }
    😱

    View Slide

  8. これはまぁそもそもコンパイラやLintで検出すべきだけど

    View Slide

  9. これはどうやったら検出できる?
    const orgRepository = {
    addMember(userId: string, organizationId: string) {
    return db.save({ userId, organizationId });
    }
    }
    const orgService = {
    addMember(organizationId: string, userId: string) {
    orgRepository.addMember(organizationId, userId);
    }
    }

    View Slide

  10. このミスをコンパイラで検知できるとしたら?

    View Slide

  11. 名前を与える型 (Opaque Types)
    プリミティブな型をクラスでラップして「別の型」として扱う
    - Elm
    > type Email = Email String
    - Scala
    > opaque type Email = String
    - Kotlin
    > value class Email(val email: String)
    value class Email(val email: String)
    // false
    Email("[email protected]") == "[email protected]"
    // true
    Email("[email protected]") == Email("[email protected]")

    View Slide

  12. type RawPassword = string;
    type HashedPassword = string;
    const password: RawPassword = "1qaz2wsx";
    const hashed: HashedPassword = password;
    これでコンパイルエラーに!!
    TypeScript でもやってみる

    View Slide

  13. ならない

    View Slide

  14. TypeScript が採⽤しているのはStructural Typing
    - Go⾔語でも採⽤されている型の割当戦略
    - Structural → (形) 構造物の、構造(上)の via 英辞郎
    - ⽇本語だと構造的部分型
    - その名の通り「構造」に対して型がつく

    View Slide

  15. type RawPassword = string;
    type HashedPassword = string;
    同じ構造なので全て同じ string 型と⾒なされる
    type User = { name: string };
    type Organization = { name: string };
    同じ構造なので全て同じ { name: string } 型と⾒なされる
    Structural Typing

    View Slide

  16. 構造ではなく名前に型が紐づく
    - Go, TypeScript 以外の⾔語で幅広く採⽤されている型の割当戦略
    - Nominal → (形) 名前[名称]の[に関する]via 英辞郎
    - ⽇本語だと公称型
    class Email extends String {}
    class Phone extends String {}
    同じ String の拡張だけど違う型

    View Slide

  17. TypeScript では Nominal Typing できない?

    View Slide

  18. プリミティブ型と存在しないプロパティを交差型で定義する事で、 string を代⼊しようと
    すると型的にエラーになる⼀⽅、 string として扱える型が作れる。
    type Raw = string & { __brand: "Raw" };
    const raw: Raw = "password"; // Error
    // Type 'string' is not assignable to type
    '{ __brand: "Raw"; }'
    const raw: Raw = "password" as Raw;
    const identity = (text: string) => text;
    identity(rawPassword);
    Branded Type というテクニック

    View Slide

  19. ジェネリクスを使ったりして整理するとこんな感じにできる
    declare const __brand: unique symbol
    type Brand = { [__brand]: B };
    export type Branded = T & Brand;
    type Raw = Branded;
    参考⽂献: Improve Runtime Type Safety with Branded Types in TypeScript | egghead.io
    Branded Type というテクニック

    View Slide

  20. 名前もいいけど、型でも頑張る

    View Slide

  21. 型推論に惑わされない; Promise と throw

    View Slide

  22. - await キーワードを使わなくても使う
    - await キーワードを使わないなら使わない
    async キーワードは...

    View Slide

  23. 早速問題です

    View Slide

  24. type DateRange = { since: Date, until: Date };
    type doUpdate = (id: number, data: DateRange)=> Promise;
    const update = (id: number, since: Date, until: Date) => {
    if (since.getTime() > until.getTime()) {
    throw new Error('Since cannot be before until');
    }
    return doUpdate(id, { since, until, });
    };
    この update 関数の返り値、型推論の結果は?

    View Slide

  25. Promise

    View Slide

  26. はい、Promise になります。
    const update:
    (id: number, since: Date, until: Date)=>
    Promise

    View Slide

  27. テストを書いてみる

    View Slide

  28. Jest で書いてます。
    describe('update 関数', () => {
    it('until が since よりも小さい場合に例外を投げる',
    async () => {
    const since = new Date();
    const until = new Date(0);
    await expect(update(1, since,
    until).rejects.toThrow()
    });
    });

    View Slide

  29. このテストは例外を吐いて死にます

    View Slide

  30. 例外が出ること期待してるのに?例外吐いてエラー???
    describe('update 関数', () => {
    it('until が since よりも小さい場合に例外を投げる',
    async () => {
    const since = new Date();
    const until = new Date(0);
    await expect(update(1, since,
    until).rejects.toThrow()
    });
    });

    View Slide

  31. 何が起きているのか?

    View Slide

  32. type DateRange = { since: Date, until: Date };
    type doUpdate = (id: number, data: DateRange)=>
    Promise;
    const update = (id: number, since: Date, until: Date) => {
    if (since.getTime() > until.getTime()) {
    return new Error('Since cannot be before until');
    }
    return doUpdate(id, { since, until, });
    };
    この update 関数の返り値、型推論の結果は?

    View Slide

  33. Promise | Error;

    View Slide

  34. Error が Promise の 外 にある

    View Slide

  35. この Jest で求めていた型は
    describe('update 関数', () => {
    it('until が since よりも⼩さい場合に例外を投げる',
    async () => {
    const since = new Date();
    const until = new Date(0);
    await expect(update(1, since,
    until).rejects.toThrow()
    });
    });

    View Slide

  36. Promise;
    ☢ 正しくないよ!ニュアンスだよ!!

    View Slide

  37. type DateRange = { since: Date, until: Date };
    type doUpdate = (id: number, data: DateRange)=> Promise;
    const update = (id: number, since: Date, until: Date) => {
    if (since.getTime() > until.getTime()) {
    return Promise.reject(new Error('Since cannot be before until'));
    }
    return doUpdate(id, { since, until, });
    };
    どう書くべきか? Promise を返す

    View Slide

  38. type DateRange = { since: Date, until: Date };
    type doUpdate = (id: number, data: DateRange)=> Promise;
    const update = async (id: number, since: Date, until: Date) => {
    if (since.getTime() > until.getTime()) {
    return Promise.reject(new Error('Since cannot be before
    until'));
    }
    return doUpdate(id, { since, until, });
    };
    どう書くべきか? async キーワードをつける

    View Slide

  39. 例外は型をすり抜ける
    から気をつけようねというお話しでした。

    View Slide

  40. 他のアプローチ
    - Go の様にエラー返す
    - Haskell の Either 型や Rust の Result 型みたいにラップして返す
    結局は「エラーが起きうることを明⽰する」という⼿法
    Java の throws もそういう意味ではエラーの可視化がされていたなと

    View Slide

  41. 閑話休題: git alias の話

    View Slide

  42. - alias.me !git config --get-regexp user
    - alias.aliass !git config --get-regexp alias
    - alias.amend commit --amend
    - alias.wip commit --no-verify -m WIP
    - alias.cancel reset --mixed HEAD^
    - alias.forcepush push --force-with-lease
    - alias.purr pull --rebase
    - alias.nerge merge --no-ff
    - alias.ibase !sh -c 'git rebase -i --autosquash $(git merge-base ${1:-main} HEAD)' -
    - alias.l log --oneline
    - alias.lm log --oneline origin/main..
    - alias.lp log --abbrev-commit --color --pretty=format:'%C(yellow)%h%Creset -
    %C(auto)%d%Creset %s %C(blue)(%cd)%C(red)<%an>%Creset' --date iso
    - alias.ls log --decorate --oneline --stat
    - alias.noskip update-index --no-skip-worktree
    - alias.plune !git branch --merged | grep -vE '(main|master|develop|release|gh-pages|¥*)' | xargs git branch -d
    - alias.pwd rev-parse --show-prefix
    - alias.skip update-index --skip-worktree
    - alias.stat !cd -- ${GIT_PREFIX:-.} && git stash list && git status --short --branch
    - alias.up !git branch -u origin/$(git branch --show-current)
    - alias.wdiff diff --ignore-all-space --word-diff

    View Slide

  43. alias.files !cd ${GIT_PREFIX:-.} && git ls-tree -z --name-
    only HEAD | xargs -0 -n1 -I@ -- git log -1 --
    pretty=format:'%ai %h @ (%ar) <%an>' -- @
    ※ ⾊関連のコマンドはスペースの都合上省略しました

    View Slide

  44. NestJS でのモジュール設計

    View Slide

  45. という名の、こういう時ってどうするんですかね?話

    View Slide

  46. @Module({ imports: [EventModule] })
    export class ApplicationModule {}
    NestJSいいですよね。
    @Module({
    controllers: [UserController],
    providers: [UserService,
    UserRepository],
    exports: [UserService],
    })
    export class UserModule {}
    @Module({
    imports: [UserModule],
    controllers: [EventController],
    providers: [EventService],
    exports: [EventService],
    })
    export class EventModule {}

    View Slide

  47. モジュール設計を考えるいい機会
    ここは User ではなく
    Participant かも?
    純粋なUser?それとも
    ログインしたUser?
    @Module({ imports: [EventModule] })
    export class ApplicationModule {}
    @Module({
    controllers: [UserController],
    providers: [UserService,
    UserRepository],
    exports: [UserService],
    })
    export class UserModule {}
    @Module({
    imports: [UserModule],
    controllers: [EventController],
    providers: [EventService],
    exports: [EventService],
    })
    export class EventModule {}

    View Slide

  48. UserModule と AuthModule

    View Slide

  49. UserModule
    import: [AuthModule]
    provider: [UserService, UserRepository]
    exports: [UserService]
    モジュールが相互依存するシーン
    AuthModule
    import: [UserModule]
    provider: [AuthService]
    exports: [UserService]
    ユーザーのプロフィール編集するために、認証認可を確認したい
    ユーザーの認証認可をするために、ユーザー情報を取得したい

    View Slide

  50. どう循環を剥がすか?

    View Slide

  51. UserModule
    provider: [UserRepository]
    export: [UserService]
    2つのモジュールに依存するモジュールを作る
    AuthModule
    provider: [UserRepository]
    export: [AuthService]
    AuthUserModule
    import: [UserModule, AuthModule]

    View Slide

  52. 2つのモジュールに依存するモジュールを作る
    UserModule
    provider: [UserRepository]
    export: [UserService]
    AuthModule
    provider: [UserRepository]
    export: [AuthService]
    AuthUserModule
    import: [UserModule, AuthModule]

    View Slide

  53. 結局欲しいのは UserRepository へのアクセス?

    View Slide

  54. UserModule
    provider: [UserRepository]
    export: [UserService]
    Repository を Module と分離させた
    AuthModule
    provider: [UserRepository]
    export: [AuthService]
    UserRepository

    View Slide

  55. - SlidesCodeHighlighter
    - https://romannurik.github.io/SlidesCodeHighlighter
    - スライドに貼るためのコードをいい感じに⽣成してくれる
    - Improve Runtime Type Safety with Branded Types in TypeScript
    - https://egghead.io/blog/using-branded-types-in-typescript
    - Branded Type についてのいい感じの記事
    - NestJS
    - https://docs.nestjs.com/
    - お世話になりました
    - 元ネタ
    - TypeScript でも Nominal Typing がしたい
    > https://qiita.com/takayukioda/items/32de7f05b7dd9e025ce7
    > ありがとう2019年の⾃分
    - Promiseを返す関数で throw する時の注意点
    > https://qiita.com/takayukioda/items/df0e0320d803bf28ba22
    > ありがとう2020年の⾃分
    ツールとか参考⽂献

    View Slide

  56. Sansan 技術本部
    Digitization部 採⽤情報
    https://media.sansan-engineering.com/digitization

    View Slide

  57. View Slide