TypeScript Logging from Scratch: Isomorphic, Performant, and Extensible
How to build a fast, lightweight, isomorphic logger in TypeScript with custom transports, performance tuning, and extensible design.
Logging is one of those things every application needs, but it’s easy to take for granted. You reach for Winston, Pino, or Bunyan without thinking twice. But what if you want something tailored to your exact needs? Something lightweight, isomorphic (working in both Node.js and browsers), and extensible?
In this article, we’ll build a production-ready logger from scratch in TypeScript. Along the way, you’ll learn about structured logging, transport patterns, performance optimization, and how to design a truly extensible logging API.
Why Build Your Own Logger?
Before we dive in, let’s address the elephant in the room: why not just use an existing library?
Valid reasons to build your own:
- You need isomorphic support with minimal bundle size for browser environments
- Existing libraries are too heavyweight for your use case
- You want full control over formatting and transport mechanisms
- You’re building a library and don’t want heavy dependencies
- You need custom features not available in popular loggers
When to use existing libraries:
- You need battle-tested reliability immediately
- You want extensive ecosystem integration (APM, cloud logging services)
- Time-to-market is critical
For this tutorial, we’ll assume you have valid reasons to build custom. Let’s get started.
Designing the API: Log Levels and Structured Logging
A good logger needs a clean, intuitive API. Let’s start with log levels:
enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
FATAL = 4,
}
interface LogEntry {
level: LogLevel;
message: string;
timestamp: Date;
context?: Record<string, unknown>;
error?: Error;
}
Structured logging means treating logs as data, not just text. Instead of string interpolation like "User ${id} logged in", we separate the message from the data:
// Bad: unstructured
logger.info(`User ${userId} logged in from ${ip}`);
// Good: structured
logger.info('User logged in', { userId, ip });
This makes logs searchable, filterable, and much more valuable in production.
The Core Logger Interface
Let’s design our logger’s public API:
interface ILogger {
debug(message: string, context?: Record<string, unknown>): void;
info(message: string, context?: Record<string, unknown>): void;
warn(message: string, context?: Record<string, unknown>): void;
error(message: string, error?: Error, context?: Record<string, unknown>): void;
fatal(message: string, error?: Error, context?: Record<string, unknown>): void;
// Child loggers inherit parent context
child(context: Record<string, unknown>): ILogger;
}
The child method is powerful for adding request-scoped context without passing it to every log call:
// In an Express middleware
app.use((req, res, next) => {
req.logger = logger.child({ requestId: generateId(), userId: req.user?.id });
next();
});
// In a route handler
req.logger.info('Processing order'); // Automatically includes requestId and userId
Building the Core Logger
Here’s our basic logger implementation:
class Logger implements ILogger {
private minLevel: LogLevel;
private transports: Transport[] = [];
private baseContext: Record<string, unknown> = {};
constructor(options: LoggerOptions = {}) {
this.minLevel = options.minLevel ?? LogLevel.INFO;
this.transports = options.transports ?? [new ConsoleTransport()];
this.baseContext = options.context ?? {};
}
private log(level: LogLevel, message: string, context?: Record<string, unknown>, error?: Error): void {
if (level < this.minLevel) return;
const entry: LogEntry = {
level,
message,
timestamp: new Date(),
context: { ...this.baseContext, ...context },
error,
};
for (const transport of this.transports) {
transport.log(entry);
}
}
debug(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.DEBUG, message, context);
}
info(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.INFO, message, context);
}
warn(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.WARN, message, context);
}
error(message: string, error?: Error, context?: Record<string, unknown>): void {
this.log(LogLevel.ERROR, message, context, error);
}
fatal(message: string, error?: Error, context?: Record<string, unknown>): void {
this.log(LogLevel.FATAL, message, context, error);
}
child(context: Record<string, unknown>): ILogger {
return new Logger({
minLevel: this.minLevel,
transports: this.transports,
context: { ...this.baseContext, ...context },
});
}
}
Creating Transports
Transports determine where logs go. Let’s build three: console, file (Node.js only), and HTTP.
Console Transport
class ConsoleTransport implements Transport {
private formatter: Formatter;
constructor(formatter?: Formatter) {
this.formatter = formatter ?? new JSONFormatter();
}
log(entry: LogEntry): void {
const formatted = this.formatter.format(entry);
switch (entry.level) {
case LogLevel.DEBUG:
case LogLevel.INFO:
console.log(formatted);
break;
case LogLevel.WARN:
console.warn(formatted);
break;
case LogLevel.ERROR:
case LogLevel.FATAL:
console.error(formatted);
break;
}
}
}
File Transport (Node.js)
import { appendFile } from 'fs/promises';
import { join } from 'path';
class FileTransport implements Transport {
private filePath: string;
private formatter: Formatter;
constructor(filePath: string, formatter?: Formatter) {
this.filePath = filePath;
this.formatter = formatter ?? new JSONFormatter();
}
async log(entry: LogEntry): Promise<void> {
const formatted = this.formatter.format(entry);
await appendFile(this.filePath, formatted + '\n', 'utf-8');
}
}
HTTP Transport
class HTTPTransport implements Transport {
private endpoint: string;
private batchSize: number;
private batch: LogEntry[] = [];
private flushInterval: number;
private timerId?: NodeJS.Timeout | number;
constructor(endpoint: string, options: HTTPTransportOptions = {}) {
this.endpoint = endpoint;
this.batchSize = options.batchSize ?? 10;
this.flushInterval = options.flushInterval ?? 5000;
this.startFlushTimer();
}
log(entry: LogEntry): void {
this.batch.push(entry);
if (this.batch.length >= this.batchSize) {
this.flush();
}
}
private startFlushTimer(): void {
this.timerId = setInterval(() => this.flush(), this.flushInterval);
}
private async flush(): Promise<void> {
if (this.batch.length === 0) return;
const logsToSend = [...this.batch];
this.batch = [];
try {
await fetch(this.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(logsToSend),
});
} catch (error) {
console.error('Failed to send logs to HTTP endpoint', error);
// Consider re-queueing or storing failed logs
}
}
async destroy(): Promise<void> {
if (this.timerId) {
clearInterval(this.timerId);
}
await this.flush();
}
}
Making It Isomorphic
To work in both Node.js and browsers, we need to handle environment differences:
// Detect environment
const isNode = typeof process !== 'undefined' &&
process.versions != null &&
process.versions.node != null;
class LoggerFactory {
static create(options: LoggerOptions = {}): ILogger {
const transports: Transport[] = [];
if (isNode) {
// Node.js environment
transports.push(
new ConsoleTransport(new PrettyFormatter()),
new FileTransport('./logs/app.log')
);
} else {
// Browser environment
transports.push(new ConsoleTransport(new PrettyFormatter()));
// Only add HTTP transport if configured
if (options.httpEndpoint) {
transports.push(new HTTPTransport(options.httpEndpoint));
}
}
return new Logger({ ...options, transports });
}
}
Performance Optimization: Lazy Evaluation
Log messages might include expensive operations:
// Bad: always computes, even if DEBUG is disabled
logger.debug(`User data: ${JSON.stringify(getUserData())}`);
Solution: use lazy evaluation with functions:
interface ILogger {
debug(message: string | (() => string), context?: Record<string, unknown>): void;
// ... other methods
}
class Logger implements ILogger {
debug(message: string | (() => string), context?: Record<string, unknown>): void {
if (LogLevel.DEBUG < this.minLevel) return; // Early exit
const resolvedMessage = typeof message === 'function' ? message() : message;
this.log(LogLevel.DEBUG, resolvedMessage, context);
}
}
// Usage
logger.debug(() => `User data: ${JSON.stringify(getUserData())}`);
Performance Optimization: Batching
For high-throughput scenarios, batch logs before writing:
class BatchingTransport implements Transport {
private innerTransport: Transport;
private batch: LogEntry[] = [];
private batchSize: number;
private flushInterval: number;
private timerId?: NodeJS.Timeout | number;
constructor(transport: Transport, options: BatchOptions = {}) {
this.innerTransport = transport;
this.batchSize = options.batchSize ?? 100;
this.flushInterval = options.flushInterval ?? 1000;
this.timerId = setInterval(() => this.flush(), this.flushInterval);
}
log(entry: LogEntry): void {
this.batch.push(entry);
if (this.batch.length >= this.batchSize) {
this.flush();
}
}
private flush(): void {
if (this.batch.length === 0) return;
const toFlush = [...this.batch];
this.batch = [];
for (const entry of toFlush) {
this.innerTransport.log(entry);
}
}
}
Extensibility: Formatters
Different outputs need different formats. Create a formatter interface:
interface Formatter {
format(entry: LogEntry): string;
}
class JSONFormatter implements Formatter {
format(entry: LogEntry): string {
return JSON.stringify({
level: LogLevel[entry.level],
message: entry.message,
timestamp: entry.timestamp.toISOString(),
...entry.context,
error: entry.error ? {
message: entry.error.message,
stack: entry.error.stack,
} : undefined,
});
}
}
class PrettyFormatter implements Formatter {
private colors = {
[LogLevel.DEBUG]: '\x1b[36m', // Cyan
[LogLevel.INFO]: '\x1b[32m', // Green
[LogLevel.WARN]: '\x1b[33m', // Yellow
[LogLevel.ERROR]: '\x1b[31m', // Red
[LogLevel.FATAL]: '\x1b[35m', // Magenta
};
private reset = '\x1b[0m';
format(entry: LogEntry): string {
const color = this.colors[entry.level];
const level = LogLevel[entry.level].padEnd(5);
const timestamp = entry.timestamp.toISOString();
let output = `${color}${level}${this.reset} [${timestamp}] ${entry.message}`;
if (entry.context && Object.keys(entry.context).length > 0) {
output += `\n ${JSON.stringify(entry.context, null, 2)}`;
}
if (entry.error) {
output += `\n ${entry.error.stack}`;
}
return output;
}
}
Extensibility: Middleware Pattern
Add middleware for cross-cutting concerns:
type LogMiddleware = (entry: LogEntry, next: () => void) => void;
class Logger implements ILogger {
private middleware: LogMiddleware[] = [];
use(middleware: LogMiddleware): void {
this.middleware.push(middleware);
}
private log(level: LogLevel, message: string, context?: Record<string, unknown>, error?: Error): void {
if (level < this.minLevel) return;
const entry: LogEntry = {
level,
message,
timestamp: new Date(),
context: { ...this.baseContext, ...context },
error,
};
let index = 0;
const next = () => {
if (index < this.middleware.length) {
const middleware = this.middleware[index++];
middleware(entry, next);
} else {
// Final step: send to transports
for (const transport of this.transports) {
transport.log(entry);
}
}
};
next();
}
}
// Example middleware: add hostname
const hostnameMiddleware: LogMiddleware = (entry, next) => {
entry.context = { ...entry.context, hostname: os.hostname() };
next();
};
logger.use(hostnameMiddleware);
Putting It All Together
Here’s how you’d use the complete logger:
import { LoggerFactory, LogLevel, PrettyFormatter, FileTransport } from './logger';
// Create logger
const logger = LoggerFactory.create({
minLevel: LogLevel.DEBUG,
httpEndpoint: 'https://logs.example.com/ingest',
});
// Add middleware
logger.use((entry, next) => {
entry.context = { ...entry.context, appVersion: '1.0.0' };
next();
});
// Use it
logger.info('Application started');
const requestLogger = logger.child({ requestId: '123' });
requestLogger.debug('Processing request', { method: 'GET', path: '/api/users' });
try {
throw new Error('Database connection failed');
} catch (error) {
requestLogger.error('Failed to connect to database', error as Error, {
host: 'db.example.com',
port: 5432,
});
}
Conclusion
Building a logger from scratch teaches you about API design, performance optimization, and extensibility patterns. While production applications often benefit from mature libraries like Pino or Winston, understanding the fundamentals helps you make better choices and customize when needed.
Key takeaways:
- Design for structured logging from the start
- Use the transport pattern for flexibility
- Make it isomorphic by abstracting platform-specific code
- Optimize with lazy evaluation and batching
- Enable extensibility through formatters and middleware
The complete source code with tests is available on GitHub. Happy logging!