TypeScript для системного программирования: когда JavaScript встречает железо
Когда мы говорим «системное программирование», в голову обычно приходят C, Rust, а может быть, Go или Zig. Но TypeScript? Тот самый язык, который «просто компилируется в JavaScript»? Поверьте, это не шутка. TypeScript проникает туда, где раньше царили указатели, сырая память и ручное управление ресурсами. И делает это элегантно.
В этой статье мы разберем, как TypeScript можно применять для задач, традиционно считающихся «системными»: работа с памятью, взаимодействие с нативным кодом (FFI), асинхронный ввод-вывод на уровне ядра, написание драйверов и даже embedded-разработка. Мы не будем утверждать, что TypeScript заменит C — но покажем, где его сильные стороны дают фору классическим подходам.
Почему TypeScript вообще рассматривают для системного программирования?
Классический аргумент против TypeScript — это сборка мусора и виртуальная машина. Да, это так. Но современный Node.js (и Deno, и Bun) работает на движке V8, который по производительности вплотную приближается к компилируемым языкам во многих сценариях. А главное — TypeScript дает вам безопасность типов, богатую экосистему и скорость разработки, недоступную чистому C.
Для системного программирования на TypeScript ключевыми преимуществами становятся:
- Строгая типизация — ловит ошибки на этапе компиляции, а не во время выполнения
- Современный async/await — идеально для неблокирующего I/O
- Богатые возможности для метапрограммирования (декораторы, дженерики, условные типы)
- Экосистема npm — тысячи готовых модулей для работы с системными вызовами
- Возможность писать на одном языке и фронтенд, и бэкенд, и системные утилиты
Конечно, это не означает, что вы напишете ядро ОС на TypeScript. Но для инструментов командной строки, сетевых демонов, утилит мониторинга и даже некоторых драйверов — это отличный выбор.
Работа с памятью в TypeScript: не так страшно, как кажется
Один из главных страхов системных программистов — потеря контроля над памятью. В TypeScript вы не можете напрямую выделять и освобождать память, как в C. Но вы можете использовать ArrayBuffer, DataView и TypedArray для работы с сырыми бинарными данными.
Вот реальный пример — чтение бинарного заголовка PNG-файла без сторонних библиотек:
function readPNGHeader(buffer: ArrayBuffer): { width: number, height: number } | null {
const view = new DataView(buffer);
// Проверяем сигнатуру PNG (8 байт)
const signature = new Uint8Array(buffer, 0, 8);
const pngSignature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
for (let i = 0; i < 8; i++) {
if (signature[i] !== pngSignature[i]) return null;
}
// Читаем IHDR chunk (смещение 16 байт: 8 сигнатура + 4 длина + 4 тип)
const width = view.getUint32(16, false); // Big-endian
const height = view.getUint32(20, false); // Big-endian
return { width, height };
}
// Использование:
const fs = require('fs');
const data = fs.readFileSync('image.png');
const result = readPNGHeader(data.buffer);
console.log(result); // { width: 1920, height: 1080 }
Как видите, мы работаем с памятью практически на уровне C — используем смещения, порядок байтов, сырые буферы. При этом TypeScript проверяет типы и защищает от глупых ошибок вроде передачи строки вместо числа.
FFI (Foreign Function Interface) — мост в нативный код
Когда производительности TypeScript не хватает, на помощь приходит FFI. С помощью модуля ffi-napi (для Node.js) вы можете вызывать функции из нативных библиотек (.so, .dll, .dylib) напрямую.
Пример — вызов функции getpid() из libc на Linux:
import ffi from 'ffi-napi';
const libc = ffi.Library(null, {
'getpid': ['int', []]
});
console.log(`PID процесса: ${libc.getpid()}`);
А вот пример посложнее — работа с системным вызовом stat() для получения информации о файле:
import ffi from 'ffi-napi';
import ref from 'ref-napi';
// Определяем структуру stat (упрощенно)
const StatStruct = ref.defineStruct('stat', [
['dev_t', 'uint64'],
['ino_t', 'uint64'],
['mode_t', 'uint32'],
['nlink_t', 'uint32'],
['uid_t', 'uint32'],
['gid_t', 'uint32'],
['size', 'int64'],
// ... остальные поля
]);
const libc = ffi.Library(null, {
'stat': ['int', ['string', ref.refType(StatStruct)]]
});
const statBuf = new StatStruct();
const result = libc.stat('/etc/passwd', statBuf.ref());
if (result === 0) {
console.log(`Размер файла: ${statBuf.size} байт`);
console.log(`UID владельца: ${statBuf.uid_t}`);
}
Такой подход позволяет писать системные утилиты на TypeScript, которые работают с «железом» и ядром ОС, сохраняя при этом безопасность типов и скорость разработки.
Асинхронный I/O на уровне ядра
Одна из сильнейших сторон TypeScript (и JavaScript вообще) — это неблокирующий асинхронный I/O. В системном программировании это критично, когда нужно работать с сетью, файлами или устройствами без блокировки основного потока.
Современный Node.js использует libuv — кроссплатформенную библиотеку, которая абстрагирует системные вызовы epoll (Linux), kqueue (macOS) и IOCP (Windows). Это значит, что ваш TypeScript-код может эффективно обрабатывать тысячи одновременных соединений, не создавая потоков.
Пример — асинхронное чтение большого файла с контролем прогресса:
import { createReadStream } from 'fs';
import { createHash } from 'crypto';
async function hashFileWithProgress(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash('sha256');
const stream = createReadStream(filePath);
const totalSize = fs.statSync(filePath).size;
let processed = 0;
stream.on('data', (chunk: Buffer) => {
processed += chunk.length;
const percent = ((processed / totalSize) * 100).toFixed(1);
process.stdout.write(`\rПрогресс: ${percent}%`);
hash.update(chunk);
});
stream.on('end', () => {
console.log('\nГотово!');
resolve(hash.digest('hex'));
});
stream.on('error', reject);
});
}
// Использование
hashFileWithProgress('/path/to/large/file.iso')
.then(hash => console.log(`