На этой странице

Эта статья охватывает моменты, касающиеся именно публикации TypeScript. Под публикацией понимается распространение в виде пакета через npm (или другой менеджер пакетов); это не про компиляцию приложения/сервера для запуска в production (например, PWA и/или endpoint-сервер).

Несколько важных моментов:

  • Всё из статьи Публикация пакета применимо и здесь.

    • Поля вроде main оперируют опубликованным содержимым, поэтому, когда исходный код на TypeScript транспилируется в JavaScript, опубликованным содержимым является JavaScript, и main указывал бы на JavaScript-файл с расширением JavaScript-файла (например, main.ts → "main": "main.js").

    • Поля вроде scripts.test оперируют исходным кодом, поэтому они использовали бы расширения файлов исходного кода (например, "test": "node --test './src/**/*.test.ts').

  • Node выполняет код на TypeScript через процесс под названием «отбрасывание типов (type stripping)», в котором node (через Amaro) удаляет специфичный для TypeScript синтаксис, оставляя обычный JavaScript (который node уже понимает). Это поведение включено по умолчанию начиная с node версии 22.18.0.

    • Node не отбрасывает типы в node_modules, потому что это может вызвать значительные проблемы с производительностью официального компилятора TypeScript (tsc) и частей VS Code, поэтому сопровождающие TypeScript хотели бы отговорить людей от публикации сырого TypeScript, по крайней мере пока.
  • Использование специфичных для TypeScript возможностей вроде enum в node всё ещё требует флага (--experimental-transform-types). Для них так или иначе часто есть лучшие альтернативы.

    • Чтобы гарантировать, что специфичных для TypeScript возможностей нет (и ваш код мог просто работать в node), установите опцию конфигурации erasableSyntaxOnly в TypeScript версии 5.8+.
  • Используйте dependabot, чтобы держать зависимости актуальными, включая зависимости в github actions. Это очень простая конфигурация по принципу «настроил и забыл».

  • .nvmrc происходит из nvm — менеджера множества версий node. Он позволяет указать версию node, которую проект в целом должен использовать.

Обзор структуры директорий репозитория выглядел бы примерно так:

example-ts-pkg/
├ .github/
│ ├ workflows/
│ │ ├ ci.yml
│ │ └ publish.yml
│ └ dependabot.yml
├ src/
│ ├ foo.fixture.js
│ ├ main.ts
│ ├ main.test.ts
│ ├ some-util.ts
│ └ some-util.test.ts
├ LICENSE
├ package.json
├ README.md
└ tsconfig.json

А обзор структуры директорий его опубликованного пакета выглядел бы примерно так:

example-ts-pkg/
├ LICENSE
├ main.d.ts
├ main.d.ts.map
├ main.js
├ package.json
├ README.md
├ some-util.d.ts
├ some-util.d.ts.map
└ some-util.js

Замечание об организации директорий: есть несколько распространённых практик размещения тестов. Принцип наименьшего знания (principle of least knowledge) говорит располагать их рядом (co-locate) — рядом с реализацией. Иногда это в той же директории, иногда — в «ящичке» вроде __test__ (тоже рядом с реализацией, «Files co-located but segregated»). Как вариант, некоторые предпочитают создавать test/ рядом с src/ («'src' and 'test' fully segregated») — либо с зеркальной структурой, либо как «свалку» (junk drawer).

Назначение типов — предупредить, что реализация не будет работать:

// @errors: 2322
const foo = 'a';
const bar: number = 1 + foo;

TypeScript предупредил, что код выше не будет вести себя как задумано, — точно так же, как модульный тест предупреждает, что код ведёт себя не так, как задумано. Они дополняют друг друга и проверяют разные вещи — у вас должно быть и то, и другое.

Ваш редактор (например, VS Code), скорее всего, имеет встроенную поддержку TypeScript, отображая ошибки по ходу работы. Если нет и/или вы их пропустили, вас подстрахует CI.

Следующий GitHub Action настраивает CI-задачу, которая автоматически проверяет (и требует), чтобы типы прошли инспекцию для PR в ветку main.

# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json

name: Tests

on:
  pull_request:
    branches: ['*']

jobs:
  check-types:
    # Separate these from tests because
    # they are platform and node-version independent
    # and need be run only once.

    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: '.nvmrc'
          cache: 'npm'
      - name: npm clean install
        run: npm ci
      # You may want to run a lint check here too
      - run: node --run types:check

  get-matrix:
    # Automatically pick active LTS versions
    runs-on: ubuntu-latest
    outputs:
      latest: ${{ steps.set-matrix.outputs.requireds }}
    steps:
      - uses: ljharb/actions/node/matrix@main
        id: set-matrix
        with:
          versionsAsRoot: true
          type: majors
          preset: '>= 22' # glob is not backported below 22.x

  test:
    needs: [get-matrix]
    runs-on: ${{ matrix.os }}

    strategy:
      fail-fast: false
      matrix:
        node-version: ${{ fromJson(needs.get-matrix.outputs.latest) }}
        os:
          - macos-latest
          - ubuntu-latest
          - windows-latest

    steps:
      - uses: actions/checkout@v4
      - name: Use node ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - name: npm clean install
        run: npm ci
      - run: node --run test

Обратите внимание, что к тестовым файлам вполне может применяться другой tsconfig.json (поэтому они и исключены в примере выше).

Объявления типов (.d.ts и компания) предоставляют информацию о типах в виде прилагаемого (sidecar) файла, позволяя исполняемому коду оставаться обычным JavaScript, но всё же иметь типы.

Поскольку они генерируются на основе исходного кода, их можно собирать в рамках вашего процесса публикации и не нужно коммитить в репозиторий.

Возьмём следующий пример, где объявления типов генерируются прямо перед публикацией в реестр npm.

# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json

# This is mostly boilerplate.

name: Publish to npm
on:
  push:
    tags:
      - '**@*'

jobs:
  build:
    runs-on: ubuntu-latest

    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: '.nvmrc'
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci

      # - name: Publish to npm
      #   run: … npm publish …

Вам стоит публиковать пакет, скомпилированный с поддержкой всех LTS-версий Node.js, поскольку вы не знаете, на какой версии будет работать потребитель; tsconfig в этой статье поддерживают node 18.x и новее.

npm publish автоматически запускает prepack заранее. npm также автоматически запускает prepack перед npm pack --dry-run (чтобы вы могли легко увидеть, каким будет ваш опубликованный пакет, фактически не публикуя его). Осторожно, node --run этого не делает. Для этого шага нельзя использовать node --run, так что здесь эта оговорка неприменима, но она может касаться других шагов.

Шаги для собственно публикации в npm будут включены в отдельную статью (есть несколько плюсов и минусов за рамками этой статьи).

Генерация объявлений типов детерминирована: из одного и того же ввода вы каждый раз получаете один и тот же вывод. Поэтому нет нужды коммитить их в git.

npm publish захватывает всё применимое и доступное в момент запуска команды; поэтому генерация объявлений типов непосредственно перед этим означает, что они доступны и будут подхвачены.

По умолчанию npm publish захватывает (почти) всё (см. Files included in package). Чтобы держать ваш опубликованный пакет минимальным (вспомните мем «Heaviest Objects in the Universe» про node_modules), вам нужно исключить определённые файлы (например, тесты и тестовые фикстуры) из упаковки. Добавьте их в opt-out-список, указанный в .npmignore; убедитесь, что исключение !*.d.ts присутствует, иначе сгенерированные объявления типов не будут опубликованы! Как вариант, можно использовать package.json «files», чтобы создать opt-in (если случайно ошибиться и пропустить файл, ваш пакет может оказаться сломанным для нижестоящих пользователей, так что это менее безопасный вариант).