Розширені засоби мови TypeScript

Модулі та Strict Mode

Система модулів у TypeScript, namespace, import/export та strict режим компіляції для максимальної типобезпеки

Модулі та Strict Mode

🎯 Мета розділу

  • Опанувати систему модулів ES6 у TypeScript: import, export, default exports.
  • Зрозуміти різницю між namespace та модулями та коли використовувати кожен підхід.
  • Навчитися правильно структурувати проєкт через barrel exports та re-exports.
  • Усвідомити вплив strict mode та його підопцій на типобезпеку коду.
  • Застосовувати стратегії поступової міграції legacy коду на strict mode.
  • Налаштовувати TypeScript compiler для різних target середовищ (ES5, ES2015, ES2020).

🔑 Ключові терміни

  • Module (Модуль): файл TypeScript/JavaScript з власним scope, що експортує та імпортує значення.
  • Export: оголошення елементів (змінних, функцій, класів), доступних за межами модуля.
  • Import: імпорт елементів з інших модулів у поточний модуль.
  • Barrel Export: індексний файл (index.ts), що реекспортує елементи з кількох модулів для зручності імпорту.
  • Namespace: механізм групування коду у TypeScript (застарілий підхід, замінений модулями ES6).
  • Strict Mode: набір суворих опцій компілятора TypeScript для максимальної типобезпеки.

Система модулів: історичний контекст

JavaScript не мав вбудованої системи модулів до ES2015 (ES6). До цього використовувалися різні підходи:

  • IIFE (Immediately Invoked Function Expressions) — функції, що викликаються одразу при оголошенні для створення ізольованого scope
  • CommonJS (Node.js) — require() та module.exports
  • AMD (Asynchronous Module Definition) — define() для браузерів
  • UMD (Universal Module Definition) — універсальна обгортка для сумісності

ES2015 стандартизував модулі ES6 через import та export, що стали основою сучасного JavaScript. TypeScript підтримує ES6 модулі та компілює їх у різні формати залежно від target середовища.

Що таке модуль у TypeScript

Модуль — це файл TypeScript (.ts, .tsx), що містить хоча б одне import або export оголошення. Будь-який файл з export або import автоматично стає модулем з власним scope.

// user.ts — це модуль (містить export)
export interface User {
  id: string;
  name: string;
  email: string;
}

export function createUser(name: string, email: string): User {
  return {
    id: generateId(),
    name,
    email
  };
}

Файл без import/export вважається скриптом (script), де всі оголошення потрапляють у глобальний scope і доступні у всіх інших файлах проєкту.

Script vs Module:
// global.ts — скрипт (немає export/import)
const VERSION = '1.0.0';  // глобальна змінна

// main.ts — інший файл проєкту
console.log(VERSION);  // ✅ доступно без import (якщо global.ts — скрипт)
Це може призвести до конфліктів імен. Модулі вирішують цю проблему через ізоляцію scope.

Named Exports: експорт іменованих елементів

Named export — найпоширеніший тип експорту, що дозволяє експортувати кілька елементів з одного модуля під їхніми іменами.

Синтаксис named export

// user.service.ts

// Експорт при оголошенні
export interface User {
  id: string;
  name: string;
  email: string;
}

export function getUser(id: string): User {
  // логіка отримання користувача
  return { id, name: 'Олександр', email: 'oleksandr@example.com' };
}

export function updateUser(id: string, data: Partial<User>): User {
  // логіка оновлення
  return { ...getUser(id), ...data };
}

export const MAX_USERS = 1000;

Імпорт named exports

// main.ts

// Імпорт конкретних елементів
import { User, getUser, updateUser } from './user.service';

const user: User = getUser('user-1');
console.log(user.name);

updateUser('user-1', { name: 'Марія' });

Перейменування при імпорті

// Перейменування через as
import { getUser as fetchUser, updateUser as modifyUser } from './user.service';

const user = fetchUser('user-1');
modifyUser('user-1', { email: 'new@example.com' });

Експорт списком

Замість додавання export до кожного оголошення можна експортувати список елементів наприкінці файлу:

// user.service.ts

interface User {
  id: string;
  name: string;
}

function getUser(id: string): User {
  // ...
}

function updateUser(id: string, data: Partial<User>): User {
  // ...
}

const MAX_USERS = 1000;

// Експорт списком
export { User, getUser, updateUser, MAX_USERS };

Це дає кращий огляд того, що модуль експортує, особливо у великих файлах.

Перейменування при експорті

// user.service.ts

function getUserById(id: string): User {
  // ...
}

// Експортуємо під іншою назвою
export { getUserById as getUser };
// main.ts
import { getUser } from './user.service';  // імпортуємо під експортованою назвою

const user = getUser('user-1');

Default Export: експорт за замовчуванням

Default export дозволяє експортувати один головний елемент з модуля без фіксованого імені. При імпорті можна використовувати будь-яке ім'я.

Синтаксис default export

// logger.ts

export default class Logger {
  log(message: string): void {
    console.log(`[LOG] ${message}`);
  }

  error(message: string): void {
    console.error(`[ERROR] ${message}`);
  }
}

Імпорт default export

// main.ts

// Можна використати будь-яке ім'я при імпорті
import Logger from './logger';
// або
import MyLogger from './logger';
// або
import CustomLoggerName from './logger';

const logger = new Logger();
logger.log('Сервер запущено');

Default export для функцій

// utils.ts

export default function generateId(): string {
  return Math.random().toString(36).substring(2, 15);
}
// main.ts
import generateId from './utils';

const id = generateId();
console.log(id);

Комбінація default та named exports

// api-client.ts

export interface ApiConfig {
  baseUrl: string;
  timeout: number;
}

export class HttpError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
  }
}

// Default export
export default class ApiClient {
  constructor(private config: ApiConfig) {}

  async get<T>(url: string): Promise<T> {
    // ...
  }
}
// main.ts

// Імпорт default та named разом
import ApiClient, { ApiConfig, HttpError } from './api-client';

const config: ApiConfig = {
  baseUrl: 'https://api.example.com',
  timeout: 5000
};

const client = new ApiClient(config);
Default exports: за і протиПереваги:
  • Гнучкість іменування при імпорті
  • Зручно для модулів з одним головним експортом (клас, компонент React)
Недоліки:
  • Погіршує refactoring: IDE не може автоматично перейменувати всі імпорти
  • Складніше знайти всі використання через пошук
  • Можуть виникати різні імена для одного модуля у різних частинах проєкту
Рекомендація: Для великих проєктів віддавайте перевагу named exports для кращої підтримки refactoring та consistency.

Re-exports та Barrel Pattern

Re-export (реекспорт) — це експорт елементів, імпортованих з інших модулів. Barrel pattern — це організація індексних файлів (index.ts), що реекспортують елементи з кількох модулів для зручності імпорту.

Простий re-export

// database/user-repository.ts
export class UserRepository {
  // ...
}

// database/product-repository.ts
export class ProductRepository {
  // ...
}

// database/order-repository.ts
export class OrderRepository {
  // ...
}

// database/index.ts — barrel file
export { UserRepository } from './user-repository';
export { ProductRepository } from './product-repository';
export { OrderRepository } from './order-repository';

Тепер замість трьох окремих імпортів:

// ❌ Довго
import { UserRepository } from './database/user-repository';
import { ProductRepository } from './database/product-repository';
import { OrderRepository } from './database/order-repository';

Можна імпортувати все з одного barrel файлу:

// ✅ Коротко
import { UserRepository, ProductRepository, OrderRepository } from './database';

Re-export усього модуля

// database/index.ts

// Реекспорт усього з модулів
export * from './user-repository';
export * from './product-repository';
export * from './order-repository';

Це автоматично експортує всі named exports з кожного модуля.

export * не реекспортує default exports:
// logger.ts
export default class Logger { }

// index.ts
export * from './logger';  // default export НЕ реекспортується

// main.ts
import Logger from './index';  // ❌ Error: module has no default export
Для реекспорту default потрібно явно:
// index.ts
export { default as Logger } from './logger';

Namespace: застарілий механізм групування

Namespace (раніше називався Internal Module) — це механізм TypeScript для групування коду до появи ES6 модулів. Сьогодні namespace є legacy feature і не рекомендується для нових проєктів, але важливо розуміти його для роботи зі старим кодом.

Синтаксис namespace

// validators.ts

namespace Validators {
  export interface StringValidator {
    isValid(s: string): boolean;
  }

  export class EmailValidator implements StringValidator {
    isValid(s: string): boolean {
      return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
    }
  }

  export class UrlValidator implements StringValidator {
    isValid(s: string): boolean {
      try {
        new URL(s);
        return true;
      } catch {
        return false;
      }
    }
  }

  // Приватний helper (без export)
  function normalize(s: string): string {
    return s.trim().toLowerCase();
  }
}

// Використання namespace
const emailValidator = new Validators.EmailValidator();
console.log(emailValidator.isValid('user@example.com'));  // true

Вкладені namespace

namespace App {
  export namespace Models {
    export interface User {
      id: string;
      name: string;
    }
  }

  export namespace Services {
    export class UserService {
      getUser(id: string): Models.User {
        return { id, name: 'Олександр' };
      }
    }
  }
}

const userService = new App.Services.UserService();
const user = userService.getUser('user-1');

Namespace vs Modules: коли що використовувати

ХарактеристикаNamespaceModules (ES6)
ScopeГлобальний (всі namespace у глобальному scope)Ізольований (кожен файл — окремий scope)
ЗавантаженняВсі namespace завантажуються одразуМодулі завантажуються на вимогу (tree-shaking)
ІнструментиПідтримка обмеженаПовна підтримка бандлерами (Webpack, Rollup)
RefactoringСкладніше переміщувати кодЛегко переміщувати файли
РекомендаціяВикористовувати лише для declaration files (.d.ts)Використовувати для всього коду
Єдиний сучасний use case для namespace:Namespace корисні у declaration files (.d.ts) для опису типів глобальних бібліотек:
// jquery.d.ts
declare namespace JQuery {
  interface AjaxSettings {
    url?: string;
    method?: 'GET' | 'POST';
  }
}

declare function $(selector: string): JQuery.Instance;
Для звичайного коду завжди використовуйте модулі ES6.

Strict Mode: максимальна типобезпека

Strict mode у TypeScript — це набір суворих опцій компілятора, що увімкнюються через "strict": true у tsconfig.json. Strict mode значно підвищує типобезпеку коду, виявляючи потенційні помилки на етапі компіляції.

Увімкнення strict mode

{
  "compilerOptions": {
    "strict": true
  }
}

Ця одна опція увімкнує всі strict підопції одночасно. Розглянемо кожну детально.


strictNullChecks: контроль null та undefined

strictNullChecks змушує TypeScript розрізняти типи з null/undefined та без них. Це запобігає найпоширенішим runtime помилкам — "Cannot read property 'x' of null".

Без strictNullChecks (небезпечно)

// "strictNullChecks": false

function getLength(text: string): number {
  return text.length;  // немає помилки компіляції
}

const result = getLength(null);  // ✅ компілюється, але runtime Error!
// TypeError: Cannot read property 'length' of null

З strictNullChecks (безпечно)

// "strictNullChecks": true

function getLength(text: string): number {
  return text.length;
}

// ❌ Error: Argument of type 'null' is not assignable to parameter of type 'string'
// const result = getLength(null);

// ✅ Правильно: явно вказуємо, що може бути null
function safeGetLength(text: string | null): number {
  if (text === null) {
    return 0;
  }
  return text.length;  // тут TypeScript знає, що text не null
}

const result = safeGetLength(null);  // 0

Optional chaining та nullish coalescing

З strictNullChecks корисно використовувати сучасні оператори JavaScript:

interface User {
  profile?: {
    address?: {
      city?: string;
    };
  };
}

const user: User = {};

// Optional chaining (?.) — безпечний доступ до вкладених властивостей
const city = user.profile?.address?.city;
// city: string | undefined

// Nullish coalescing (??) — значення за замовчуванням
const cityName = user.profile?.address?.city ?? 'Невідоме місто';
// cityName: string

strictFunctionTypes: коваріантність параметрів

strictFunctionTypes забороняє присвоєння функцій з більш загальними параметрами до функцій з більш специфічними параметрами (контроль contravariance).

Без strictFunctionTypes

// "strictFunctionTypes": false

interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

let handleAnimal: (animal: Animal) => void;
let handleDog: (dog: Dog) => void;

handleAnimal = (animal: Animal) => {
  console.log(animal.name);
};

// ✅ Небезпечне присвоєння дозволене
handleDog = handleAnimal;

handleDog({ name: 'Бобік', breed: 'Лабрадор' });  // працює

// Проблема: якщо handleAnimal очікує Dog з breed, виникне помилка

З strictFunctionTypes

// "strictFunctionTypes": true

// ❌ Error: Type '(animal: Animal) => void' is not assignable to type '(dog: Dog) => void'
// handleDog = handleAnimal;

// ✅ Правильно: функція з Dog може бути присвоєна до Animal
handleAnimal = handleDog;  // це безпечно (Dog є підтипом Animal)

noImplicitAny: заборона неявного any

noImplicitAny забороняє TypeScript автоматично виводити тип any, коли тип не може бути визначений. Це змушує явно анотувати типи.

Без noImplicitAny

// "noImplicitAny": false

function add(a, b) {  // a та b мають тип any неявно
  return a + b;
}

add(10, 20);      // 30
add('hello', '');  // "hello"
add({}, []);       // "[object Object]" (небажана поведінка)

З noImplicitAny

// "noImplicitAny": true

// ❌ Error: Parameter 'a' implicitly has an 'any' type
// function add(a, b) {
//   return a + b;
// }

// ✅ Правильно: явна типізація
function add(a: number, b: number): number {
  return a + b;
}

add(10, 20);      // 30
// add('hello', '');  // ❌ Error: Argument of type 'string' is not assignable to parameter of type 'number'

strictBindCallApply: типобезпечний bind/call/apply

strictBindCallApply перевіряє типи аргументів у методах bind, call та apply.

Без strictBindCallApply

// "strictBindCallApply": false

function greet(name: string, age: number): void {
  console.log(`Привіт, ${name}! Тобі ${age} років.`);
}

// ✅ Компілюється, але runtime Error
greet.call(null, 'Олександр', 'twenty');  // runtime: NaN

З strictBindCallApply

// "strictBindCallApply": true

function greet(name: string, age: number): void {
  console.log(`Привіт, ${name}! Тобі ${age} років.`);
}

// ❌ Error: Argument of type 'string' is not assignable to parameter of type 'number'
// greet.call(null, 'Олександр', 'twenty');

// ✅ Правильно
greet.call(null, 'Олександр', 28);
greet.apply(null, ['Марія', 25]);

const boundGreet = greet.bind(null, 'Ігор');
boundGreet(30);

noImplicitThis: контроль типу this

noImplicitThis вимагає явної типізації this у функціях, де контекст невідомий.

Без noImplicitThis

// "noImplicitThis": false

function logName() {
  console.log(this.name);  // this має тип any неявно
}

const user = { name: 'Олександр', logName };
user.logName();  // працює

logName();  // runtime: undefined (this === window або undefined)

З noImplicitThis

// "noImplicitThis": true

// ❌ Error: 'this' implicitly has type 'any'
// function logName() {
//   console.log(this.name);
// }

// ✅ Правильно: явна типізація this
interface User {
  name: string;
}

function logName(this: User): void {
  console.log(this.name);
}

const user: User = { name: 'Олександр' };

logName.call(user);  // "Олександр"
// logName();  // ❌ Error: The 'this' context of type 'void' is not assignable to method's 'this' of type 'User'

alwaysStrict: генерація 'use strict'

alwaysStrict додає "use strict" на початок кожного згенерованого JavaScript файлу, увімкнюючи strict mode JavaScript.

{
  "compilerOptions": {
    "alwaysStrict": true
  }
}

Згенерований JavaScript:

"use strict";

function add(a, b) {
  return a + b;
}

Strict mode JavaScript забороняє небезпечні конструкції (неоголошені змінні, with, подвійні імена параметрів тощо).


strictPropertyInitialization: ініціалізація полів класу

strictPropertyInitialization вимагає, щоб усі поля класу були ініціалізовані у конструкторі або при оголошенні.

Без strictPropertyInitialization

// "strictPropertyInitialization": false

class User {
  name: string;  // не ініціалізовано
  email: string;

  constructor(email: string) {
    this.email = email;
    // name залишається undefined!
  }
}

const user = new User('user@example.com');
console.log(user.name.toUpperCase());  // Runtime Error: Cannot read property 'toUpperCase' of undefined

З strictPropertyInitialization

// "strictPropertyInitialization": true

class User {
  name: string;  // ❌ Error: Property 'name' has no initializer
  email: string;

  constructor(email: string) {
    this.email = email;
  }
}

// ✅ Варіант 1: Ініціалізація при оголошенні
class User {
  name: string = 'Аноним';
  email: string;

  constructor(email: string) {
    this.email = email;
  }
}

// ✅ Варіант 2: Ініціалізація у конструкторі
class User {
  name: string;
  email: string;

  constructor(name: string, email: string) {
    this.name = name;
    this.email = email;
  }
}

// ✅ Варіант 3: Опціональне поле
class User {
  name?: string;  // може бути undefined
  email: string;

  constructor(email: string) {
    this.email = email;
  }
}

// ✅ Варіант 4: Definite assignment assertion (якщо впевнені в ініціалізації іншим способом)
class User {
  name!: string;  // ! каже компілятору: "я гарантую, що це буде ініціалізовано"
  email: string;

  constructor(email: string) {
    this.email = email;
    this.initialize();
  }

  private initialize(): void {
    this.name = 'Default Name';
  }
}

Поступова міграція на strict mode

Увімкнення strict: true у великому legacy проєкті може призвести до тисяч помилок компіляції одночасно. Рекомендується поступова міграція.

Стратегія 1: Увімкнення підопцій по одній

Замість увімкнення всього strict, увімкніть підопції окремо:

{
  "compilerOptions": {
    "strict": false,
    "noImplicitAny": true  // почніть з цього
  }
}

Виправте всі помилки, потім увімкніть наступну опцію:

{
  "compilerOptions": {
    "strict": false,
    "noImplicitAny": true,
    "strictNullChecks": true  // додайте наступну
  }
}

Повторюйте, поки не увімкнете всі опції або не перейдете на "strict": true.

Стратегія 2: Використання ts-ignore та ts-expect-error

Для тимчасового пропуску помилок у legacy коді:

// @ts-ignore — ігнорує помилку на наступному рядку (не рекомендується)
// @ts-ignore
const result = unsafeFunction();

// @ts-expect-error — очікує помилку (краще для legacy коду)
// @ts-expect-error: Legacy код, буде виправлено у JIRA-123
const legacyResult = oldUnsafeApi();

Різниця: @ts-expect-error видасть помилку, якщо помилки немає (код виправили, але коментар залишився), тоді як @ts-ignore просто ігнорує рядок.

Стратегія 3: Виключення legacy файлів

{
  "compilerOptions": {
    "strict": true
  },
  "exclude": [
    "src/legacy/**/*",
    "src/old-modules/**/*"
  ]
}

Поступово міграйте файли, видаляючи їх з exclude.

Найкраща практика: Для нових проєктів завжди починайте з strict: true. Виправити код одразу простіше, ніж переписувати пізніше. Для legacy проєктів використовуйте поступову міграцію.

Module Resolution: як TypeScript шукає модулі

TypeScript має дві стратегії пошуку модулів: Node та Classic (застаріла).

Node resolution (рекомендована)

{
  "compilerOptions": {
    "moduleResolution": "node"
  }
}

TypeScript шукає модулі так само, як Node.js:

  1. Файл з точною назвою
  2. <moduleName>.ts, .tsx, .d.ts
  3. <moduleName>/index.ts
  4. node_modules/<moduleName>/package.json (поле types або typings)
  5. node_modules/<moduleName>/index.d.ts

Path mapping: аліаси шляхів

Для уникнення довгих відносних шляхів (../../../utils/helpers) використовуйте path mapping:

{
  "compilerOptions": {
    "baseUrl": "./src",
    "paths": {
      "@models/*": ["models/*"],
      "@services/*": ["services/*"],
      "@utils/*": ["utils/*"]
    }
  }
}

Тепер замість:

import { User } from '../../../models/user';
import { UserService } from '../../services/user-service';

Можна писати:

import { User } from '@models/user';
import { UserService } from '@services/user-service';
Важливо: Path mapping працює лише на рівні TypeScript компілятора. Якщо використовуєте бандлер (Webpack, Rollup, Vite), потрібно налаштувати аліаси і там:
// vite.config.ts
export default {
  resolve: {
    alias: {
      '@models': '/src/models',
      '@services': '/src/services'
    }
  }
};

Підсумок та ключові висновки

Система модулів та strict mode є фундаментом професійної розробки на TypeScript, що забезпечує масштабованість, типобезпеку та підтримуваність коду.

Ключові тези розділу:

  • ES6 модулі через import/export є стандартом — використовуйте їх замість namespace.
  • Named exports кращі за default exports для refactoring та підтримки великих проєктів.
  • Barrel pattern (index.ts) спрощує імпорти, але може уповільнити збірку.
  • Namespace застарілі для звичайного коду, корисні лише у .d.ts файлах.
  • Strict mode (strict: true) значно підвищує типобезпеку та виявляє помилки раніше.
  • strictNullChecks — найважливіша опція, що запобігає "Cannot read property of null".
  • Поступова міграція legacy коду на strict mode ефективніша за одномоментну.
  • Path mapping спрощує імпорти через аліаси замість відносних шляхів.

Практичні рекомендації:

  1. Завжди використовуйте strict: true у нових проєктах з першого дня.
  2. Віддавайте перевагу named exports для кращої підтримки IDE та refactoring.
  3. Створюйте barrel files (index.ts) для груп пов'язаних модулів.
  4. Використовуйте path mapping для зручності імпортів у великих проєктах.
  5. Для legacy коду застосовуйте поступову міграцію на strict mode.
  6. Уникайте circular dependencies між модулями (A імпортує B, B імпортує A).
  7. Налаштуйте ESLint правила для консистентності імпортів (import/order, import/no-cycle).
Copyright © 2026