Модулі та Strict Mode
Модулі та 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 і доступні у всіх інших файлах проєкту.
// global.ts — скрипт (немає export/import)
const VERSION = '1.0.0'; // глобальна змінна
// main.ts — інший файл проєкту
console.log(VERSION); // ✅ доступно без import (якщо global.ts — скрипт)
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);
- Гнучкість іменування при імпорті
- Зручно для модулів з одним головним експортом (клас, компонент React)
- Погіршує refactoring: IDE не може автоматично перейменувати всі імпорти
- Складніше знайти всі використання через пошук
- Можуть виникати різні імена для одного модуля у різних частинах проєкту
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
// 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: коли що використовувати
| Характеристика | Namespace | Modules (ES6) |
|---|---|---|
| Scope | Глобальний (всі namespace у глобальному scope) | Ізольований (кожен файл — окремий scope) |
| Завантаження | Всі namespace завантажуються одразу | Модулі завантажуються на вимогу (tree-shaking) |
| Інструменти | Підтримка обмежена | Повна підтримка бандлерами (Webpack, Rollup) |
| Refactoring | Складніше переміщувати код | Легко переміщувати файли |
| Рекомендація | Використовувати лише для declaration files (.d.ts) | Використовувати для всього коду |
.d.ts) для опису типів глобальних бібліотек:// jquery.d.ts
declare namespace JQuery {
interface AjaxSettings {
url?: string;
method?: 'GET' | 'POST';
}
}
declare function $(selector: string): JQuery.Instance;
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:
- Файл з точною назвою
<moduleName>.ts,.tsx,.d.ts<moduleName>/index.tsnode_modules/<moduleName>/package.json(полеtypesабоtypings)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';
// 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 спрощує імпорти через аліаси замість відносних шляхів.
Практичні рекомендації:
- Завжди використовуйте
strict: trueу нових проєктах з першого дня. - Віддавайте перевагу named exports для кращої підтримки IDE та refactoring.
- Створюйте barrel files (
index.ts) для груп пов'язаних модулів. - Використовуйте path mapping для зручності імпортів у великих проєктах.
- Для legacy коду застосовуйте поступову міграцію на strict mode.
- Уникайте circular dependencies між модулями (A імпортує B, B імпортує A).
- Налаштуйте ESLint правила для консистентності імпортів (
import/order,import/no-cycle).
Використовуйте named exports:
- У більшості випадків (краще для refactoring)
- Для утилітних модулів з кількома функціями
- Для модулів з типами та реалізаціями
- У великих проєктах з багатьма розробниками
Використовуйте default export:
- Для React компонентів (конвенція спільноти)
- Для модулів з одним головним класом/функцією
- Коли назва модуля чітко описує експорт (Router.ts → default export Router)
Уникайте змішування default та багатьох named exports у одному файлі.
Circular dependency (циклічна залежність) виникає, коли модуль A імпортує B, а B імпортує A:
// user.service.ts
import { ProductService } from './product.service';
export class UserService {
constructor(private productService: ProductService) {}
}
// product.service.ts
import { UserService } from './user.service'; // циклічна залежність!
export class ProductService {
constructor(private userService: UserService) {}
}
Проблема: Може призвести до undefined імпортів у runtime або помилок ініціалізації.
Рішення:
- Винесення спільної залежності: Створіть окремий модуль для спільної логіки
- Dependency Injection: Передавайте залежності через параметри замість імпортів
- Інверсія залежностей: Використовуйте інтерфейси та абстракції
ESLint плагін eslint-plugin-import з правилом import/no-cycle допоможе виявити такі проблеми.
TypeScript не підтримує per-file strict конфігурацію напряму, але можна використовувати кілька tsconfig.json:
// tsconfig.strict.json — для нового коду
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true
},
"include": ["src/new-modules/**/*"]
}
// tsconfig.json — для legacy коду
{
"compilerOptions": {
"strict": false
},
"include": ["src/**/*"],
"exclude": ["src/new-modules/**/*"]
}
Потім компілюйте обидва:
tsc -p tsconfig.json && tsc -p tsconfig.strict.json
Альтернатива: використовуйте @ts-expect-error у legacy файлах та увімкніть strict глобально.
Для монорепозиторія використовуйте project references (composite):
monorepo/
├── packages/
│ ├── shared/
│ │ ├── tsconfig.json
│ │ └── src/
│ ├── api/
│ │ ├── tsconfig.json
│ │ └── src/
│ └── web/
│ ├── tsconfig.json
│ └── src/
└── tsconfig.json (корінь)
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true,
"outDir": "./dist"
}
}
// packages/api/tsconfig.json
{
"compilerOptions": {
"composite": true
},
"references": [
{ "path": "../shared" }
]
}
// tsconfig.json (корінь)
{
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
]
}
Збірка всього проєкту:
tsc --build
Project references дозволяють інкрементальну компіляцію та чіткі залежності між пакетами.