Node.js Development — Meta-Skill
A comprehensive collection of Node.js development patterns distilled from Matteo Collina's (Fastify creator, Node.js core contributor) personal skills. Covers Fastify application architecture, Node.js best practices (type stripping, streams, caching, error handling, testing), Node.js core internals (V8, libuv, N-API, C++ addons), OAuth 2.0/2.1 authorization flows, and advanced TypeScript type patterns.
Source: Adapted from mcollina/skills — 11 skills by Matteo Collina.
Table of Contents
- When to Use
- Section A: Node.js Best Practices
- Section B: Fastify Application Architecture
- Section C: Node.js Core Internals
- Section D: OAuth 2.0/2.1 with Fastify
- Section E: Advanced TypeScript Types
- Pitfalls
- Verification
When to Use
Trigger this skill when any of these appear in the prompt:
| Signal | Relevant Section |
|---|---|
| "Fastify", "Fastify plugin", "Fastify routes", "Fastify hooks" | B |
| "Node 22", "type stripping", "strip types", "native TypeScript", ".ts without build" | A |
| "streams", "pipeline", "CSV/ETL", "backpressure", "large file processing" | A |
| "graceful shutdown", "close-with-grace", "SIGTERM", "SIGINT" | A |
| "flaky tests", "node --test", "test timeout", "process did not exit" | A |
| "V8", "libuv", "event loop", "node-gyp", "N-API", "NAN", "C++ addon", "segfault" | C |
| "OAuth", "authorization code", "PKCE", "refresh token rotation", "JWT validation" | D |
| "conditional types", "infer", "mapped types", "branded types", "opaque types" | E |
| "any type", "remove any", "strict TypeScript" | E |
| "tsc --noEmit", "type error", "compiler error" | E |
General Node.js development (error handling, logging, performance, profiling, environment config) uses Section A.
Section A: Node.js Best Practices
A1. TypeScript with Type Stripping (Node 22.6+)
Use type stripping instead of build tools (ts-node, tsx). Node runs .ts files directly by removing type annotations without transpilation.
Requirements:
- Use
import typefor type-only imports - Use const objects instead of
enum - Avoid
namespaceand parameter properties - Use
.tsextensions in import paths
// greet.ts — valid type-stripped file
import type { IncomingMessage } from 'node:http';
const greet = (name: string): string => `Hello, ${name}!`;
console.log(greet('world'));
node greet.ts # runs directly, no build step
tsconfig.json for type stripping:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
}
}
A2. Error Handling
Use @fastify/create-error for typed, code-bearing errors:
import createError from '@fastify/create-error';
const NotFoundError = createError('NOT_FOUND', '%s not found', 404);
const ValidationError = createError('VALIDATION_ERROR', '%s', 400);
throw new NotFoundError('User'); // → 404, code: NOT_FOUND
throw new ValidationError('Email required'); // → 400, code: VALIDATION_ERROR
Minimal zero-dependency pattern:
interface AppErrorOptions { code: string; statusCode?: number; cause?: Error; }
function createAppError(message: string, opts: AppErrorOptions): Error {
const err = new Error(message, { cause: opts.cause });
(err as any).code = opts.code;
(err as any).statusCode = opts.statusCode ?? 500;
Error.captureStackTrace(err, createAppError);
return err;
}
function notFound(resource: string) {
return createAppError(`${resource} not found`, { code: 'NOT_FOUND', statusCode: 404 });
}
Operational vs programmer errors: Classify errors at the boundary. Operational errors (failed network call, file not found) should be handled gracefully. Programmer errors (TypeError, ReferenceError) should crash or restart.
Global async error handlers:
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
// Log and crash hard — these are programmer errors
process.exitCode = 1;
});
process.on('uncaughtException', (err, origin) => {
console.error('Uncaught Exception:', err, origin);
// Perform emergency cleanup, then exit
process.exit(1);
});
A3. Streams
Always use pipeline() from node:stream/promises — never bare .pipe():
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
await pipeline(
createReadStream('input.csv'),
createGzip(),
createWriteStream('output.csv.gz')
);
Async generator transforms (preferred over Transform class):
async function* toUpperCase(source: AsyncIterable<Buffer>): AsyncGenerator<string> {
for await (const chunk of source) {
yield chunk.toString().toUpperCase();
}
}
await pipeline(createReadStream(input), toUpperCase, createWriteStream(output));
CSV/ETL pattern — pipeline + async transform + deduplicated enrichment:
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { createCache } from 'async-cache-dedupe';
const cache = createCache({ ttl: 60, stale: 5, storage: { type: 'memory' } });
cache.define('lookupUser', async (id: string) => {
const res = await fetch(`https://api.internal/users/${id}`);
return res.json();
});
async function* enrichRows(source: AsyncIterable<Buffer>): AsyncGenerator<string> {
for await (const chunk of source) {
const row = JSON.parse(chunk.toString());
const user = await cache.lookupUser(row.userId); // deduped
yield JSON.stringify({ ...row, user });
}
}
await pipeline(createReadStream('data.ndjson'), enrichRows, createWriteStream('enriched.ndjson'));
Pitfall: Pipelines are lazy — without a consumer they never run. Always await pipeline(...) and never omit the destination stream.
A4. Caching
| Library | Use Case | Key Feature |
|---|---|---|
lru-cache |
Bounded in-memory cache (max N entries) | Fast, predictable eviction |
async-cache-dedupe |
Deduplicate concurrent async calls | Stale-while-revalidate, dedup |
// lru-cache: bounded, in-memory
import { LRUCache } from 'lru-cache';
const cache = new LRUCache<string, any>({ max: 500, ttl: 1000 * 60 * 5 });
cache.set('key', value);
const val = cache.get('key');
// async-cache-dedupe: deduplicates concurrent requests
import { createCache } from 'async-cache-dedupe';
const dedupe = createCache({ ttl: 60, stale: 10 });
dedupe.define('fetchPlan', async (id: string) => api.getPlan(id));
// 100 concurrent calls → 1 actual request
const results = await Promise.all(ids.map(id => dedupe.fetchPlan(id)));
A5. Graceful Shutdown
Use close-with-grace (from the Fastify team):
import closeWithGrace from 'close-with-grace';
import { createServer } from 'node:http';
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
});
server.listen(3000);
closeWithGrace({ delay: 10000 }, async ({ signal, err }) => {
if (err) console.error('Shutdown error:', err);
console.log(`${signal} received, closing server...`);
// 1. Stop accepting new connections
await new Promise<void>((resolve) => server.close(() => resolve()));
// 2. Drain in-flight requests (the server.close callback handles this)
// 3. Close external connections
await db.end();
console.log('Shutdown complete');
});
Signal handling order: SIGTERM/SIGINT → stop accepting new work → drain in-flight → close DB/cache → exit.
A6. Testing with Node.js Built-in Test Runner (node:test)
import { describe, it, before, after, mock } from 'node:test';
import assert from 'node:assert';
describe('UserService', () => {
let service: UserService;
before(() => { service = new UserService(); });
it('should create a user', async (t) => {
const user = await service.create({ name: 'John' });
assert.strictEqual(user.name, 'John');
assert.ok(user.id);
});
it('should reject invalid input', async (t) => {
await assert.rejects(
() => service.create({ name: '' }),
{ message: 'Name is required' }
);
});
});
Mocking with test context:
it('should send email', async (t) => {
const sendMock = t.mock.fn(async () => ({ success: true }));
const service = new EmailService({ send: sendMock });
await service.sendWelcome('user@example.com');
assert.strictEqual(sendMock.mock.calls.length, 1);
});
Fastify inject() for integration testing:
import Fastify from 'fastify';
import { test } from 'node:test';
import assert from 'node:assert';
import myApp from '../app.js';
test('GET /health returns ok', async () => {
const app = Fastify();
await app.register(myApp);
await app.ready();
const response = await app.inject({ method: 'GET', url: '/health' });
assert.strictEqual(response.statusCode, 200);
assert.deepStrictEqual(response.json(), { status: 'ok' });
});
Diagnosing flaky tests:
- Isolate the test with
--test-only(test.only()in the file) - Run with
--test-timeout=5000to surface hangs - Check for shared state, timer dependencies, or async teardown order
- Use
--test-reporter=specfor verbose output - Run individual files:
for f in src/**/*.test.ts; do echo "Running: $f"; timeout 30s node --test "$f" || echo "TIMEOUT: $f"; done
Diagnosing stuck processes (tests that "do not exit"):
- Isolate the file/test
- Run with explicit timeout and reporter
- Find open handles with
SIGUSR1(requireswhy-is-node-running) - Patch deterministic teardown in every resource-creation scope
A7. Async Patterns
- Always prefer
async/awaitover raw.then()chains - Use
Promise.allSettled()when you need results from all promises even if some reject - Use
AbortController+AbortSignalfor cancellable operations - For concurrent bounded work:
p-limitorPromise.allSettledwith chunking
// Cancellable fetch with AbortController
async function fetchWithTimeout(url: string, ms: number): Promise<Response> {
const ac = new AbortController();
const timeout = setTimeout(() => ac.abort(), ms);
try {
return await fetch(url, { signal: ac.signal });
} finally {
clearTimeout(timeout);
}
}
A8. Profiling
# CPU profile
node --cpu-prof --cpu-prof-dir=./profiles app.js
node --prof-process isolate-*.log > processed.txt
# V8 optimization tracing
node --trace-opt --trace-deopt app.js
# Checkpoint: confirm no unexpected deoptimizations
# Event loop lag
node --trace-event-categories v8,node,node.async_hooks app.js
# Heap snapshot (open chrome://inspect)
node --inspect app.js
A9. Environment Configuration
- Use
process.envwith validated defaults — never usedotenvin production - Parse and validate at startup, not at point of use
- Use
@fastify/env(see Section B) for Fastify apps
function env(key: string, fallback?: string): string {
const val = process.env[key] ?? fallback;
if (val === undefined) throw new Error(`Missing required env: ${key}`);
return val;
}
const config = {
port: parseInt(env('PORT', '3000'), 10),
dbUrl: env('DATABASE_URL'),
logLevel: env('LOG_LEVEL', 'info'),
};
Object.freeze(config);
Section B: Fastify Application Architecture
B1. Quick Start
import Fastify from 'fastify';
const app = Fastify({ logger: true });
app.get('/health', async (request, reply) => {
return { status: 'ok' };
});
const start = async () => {
await app.listen({ port: 3000, host: '0.0.0.0' });
};
start();
B2. Plugin System
Fastify provides automatic encapsulation — each app.register() creates an isolated context.
// Encapsulated plugin — decorators NOT exposed to parent
app.register(async function childPlugin(fastify) {
fastify.decorate('privateUtil', () => 'only available here');
fastify.get('/child', async function (req, reply) {
return this.privateUtil();
});
});
fastify-plugin to break encapsulation (share decorators with parent context):
import fp from 'fastify-plugin';
export default fp(async function dbPlugin(fastify, opts) {
const db = await connect(opts.connectionString);
fastify.decorate('db', db);
fastify.addHook('onClose', async () => { await db.close(); });
}, { name: 'database-plugin', dependencies: [] });
B3. JSON Schema Validation with TypeBox
Always use TypeBox for type-safe schemas:
import { Type, type Static } from '@sinclair/typebox';
const CreateUserBody = Type.Object({
name: Type.String({ minLength: 1 }),
email: Type.String({ format: 'email' }),
age: Type.Optional(Type.Integer({ minimum: 0 })),
});
type CreateUserBodyType = Static<typeof CreateUserBody>;
app.post<{ Body: CreateUserBodyType }>('/users', {
schema: { body: CreateUserBody },
}, async (req, reply) => {
// req.body is fully typed
const user = await createUser(req.body);
return user;
});
Request validates: body, querystring, params, headers. Response serializes via response schema.
B4. Request Lifecycle (Hooks)
Order of execution: onRequest → preParsing → preValidation → preHandler → handler → preSerialization → onSend → onResponse
// Global hook — applies to all routes
app.addHook('onRequest', async (request, reply) => {
// Common: rate limiting, request logging, CORS pre-flight
});
// Scoped hook — applies only to routes in this plugin
app.register(async function scopedRoutes(fastify) {
fastify.addHook('onRequest', async (req, reply) => {
// Only affects routes registered inside this plugin
});
});
B5. Authentication
import jwt from '@fastify/jwt';
app.register(jwt, { secret: process.env.JWT_SECRET! });
app.addHook('onRequest', async (request, reply) => {
try {
await request.jwtVerify();
} catch (err) {
reply.send(err);
}
});
app.get('/me', async (request) => {
return request.user; // decoded JWT payload
});
B6. Testing with inject()
import Fastify from 'fastify';
import myApp from '../app.js';
const app = Fastify();
await app.register(myApp);
await app.ready();
const res = await app.inject({
method: 'POST',
url: '/users',
payload: { name: 'John', email: 'john@test.com' },
});
assert.strictEqual(res.statusCode, 201);
assert.deepStrictEqual(res.json().name, 'John');
B7. Logging with Pino
Fastify uses Pino by default. Control via logger option:
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
},
});
Per-request logs: request.log.info('processing order') — automatically includes reqId.
B8. CORS and Security Headers
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
await app.register(cors, {
origin: ['https://app.example.com'],
credentials: true,
});
await app.register(helmet);
B9. Error Handling in Fastify
// Global error handler
app.setErrorHandler(async (error, request, reply) => {
const statusCode = error.statusCode ?? error.status ?? 500;
request.log.error({ err: error }, 'Request error');
return reply.status(statusCode).send({
error: {
code: error.code ?? 'INTERNAL_ERROR',
message: statusCode >= 500 ? 'Internal server error' : error.message,
},
});
});
B10. Full Lifecycle Recommendations
| Scenario | Reading Order |
|---|---|
| New to Fastify | Plugins → Routes → Schemas |
| Adding auth | Plugins → Hooks → Authentication |
| Performance | Schemas → Serialization → Performance |
| Testing | Routes → Testing |
| Production | Logging → Configuration → Deployment |
Section C: Node.js Core Internals
C1. V8 Engine
Garbage Collection:
- Scavenger (young generation): fast, frequent. Objects that survive 2+ collections move to old generation.
- Mark-Sweep (old generation): marks live objects, sweeps dead ones. Triggers when old gen runs out of space.
- Mark-Compact (old gen): like Mark-Sweep but also compacts memory to reduce fragmentation.
Hidden Classes and Inline Caching:
- Adding properties to an object out of order creates different hidden classes → deoptimization
- Always initialize object properties in consistent order
TurboFan JIT:
- Functions become "hot" after ~1000 calls → TurboFan optimizes them
- Deoptimization happens when assumptions change (e.g., monomorphic call becomes polymorphic)
# Trace V8 optimization
node --trace-opt --trace-deopt app.js
# CPU profiling
node --prof app.js
node --prof-process isolate-*.log > processed.txt
C2. libuv Event Loop
Phases (in order each tick):
- Timers —
setTimeout,setIntervalcallbacks - Pending callbacks — I/O callbacks deferred from previous cycle
- Idle, prepare — internal use
- I/O poll — waits for I/O events (blocking most of the time)
- Check —
setImmediatecallbacks - Close callbacks —
closeevents (e.g., socket.on('close'))
# Detect event loop lag
node --trace-event-categories v8,node,node.async_hooks app.js
# Thread pool (default 4 threads)
UV_THREADPOOL_SIZE=8 node app.js
Key rules:
setTimeout(cb, 0)runs in timers phase (after I/O poll)setImmediate(cb)runs in check phase (after I/O poll, before next timers)- In I/O callbacks,
setImmediatealways fires beforesetTimeout(cb, 0) - Never block the event loop with synchronous CPU work — use worker threads
C3. N-API and C++ Addons
// Basic N-API addon: addon.cpp
#include <node_api.h>
napi_value Add(napi_env env, napi_callback_info info) {
size_t argc = 2;
napi_value args[2];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
double a, b;
napi_get_value_double(env, args[0], &a);
napi_get_value_double(env, args[1], &b);
napi_value result;
napi_create_double(env, a + b, &result);
return result;
}
napi_value Init(napi_env env, napi_value exports) {
napi_value fn;
napi_create_function(env, nullptr, 0, Add, nullptr, &fn);
napi_set_named_property(env, exports, "add", fn);
return exports;
}
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
binding.gyp:
{
"targets": [{
"target_name": "addon",
"sources": ["addon.cpp"]
}]
}
node-addon-api (C++ wrapper, preferred for new code):
#include <napi.h>
Napi::Number Add(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
double a = info[0].As<Napi::Number>().DoubleValue();
double b = info[1].As<Napi::Number>().DoubleValue();
return Napi::Number::New(env, a + b);
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("add", Napi::Function::New(env, Add));
return exports;
}
NODE_API_MODULE(addon, Init)
Segfault in native addon — decision tree:
- Reproduce with
node --napi-modules→ rungdb, capturebt - Does
btpoint to a V8 handle scope issue? → CheckHandleScopeusage - Points to a libuv callback? → Inspect async handle lifetime and
uv_close()sequencing - No clear C++ frame? → Check JS-side type mismatches passed into the native binding
C4. Node.js Core Contribution Rules
Rebuild before testing: Node.js embeds lib/ JS into the binary via js2c. After any change to src/ or lib/:
make -j$(nproc) # rebuild
make lint # JS, C++, MD, docs, YAML
make -j$(nproc) test # or test specific area
Lint before every commit:
make -j$(nproc)
make lint
# C++ changes only:
CLANG_FORMAT_START="$(git merge-base HEAD upstream/main)" make format-cpp
git --no-pager diff --exit-code
git add -A && git commit -s # -s is mandatory (DCO sign-off)
npx core-validate-commit --no-validate-metadata HEAD
Commit message format:
{area}: imperative description of change
Body explaining why and what, not how. Use terse subsystem-prefixed titles.
NEVER add PR-URL: or Reviewed-By: — those are added when the change lands. Every commit needs git commit -s (Signed-off-by).
Section D: OAuth 2.0/2.1 with Fastify
D1. Authorization Code + PKCE
// plugins/oauth.ts
import fp from 'fastify-plugin';
import oauth2 from '@fastify/oauth2';
export default fp(async function (fastify) {
fastify.register(oauth2, {
name: 'oauth2',
scope: ['openid', 'profile', 'email'],
credentials: {
client: {
id: process.env.CLIENT_ID!,
secret: process.env.CLIENT_SECRET!,
},
auth: {
authorizeHost: process.env.AUTH_SERVER!,
authorizePath: '/authorize',
tokenHost: process.env.AUTH_SERVER!,
tokenPath: '/token',
},
},
startRedirectPath: '/login',
callbackUri: process.env.CALLBACK_URI!,
pkce: 'S256', // RFC 7636 — always for public clients
generateStateFunction: (req) => req.session.state = crypto.randomUUID(),
checkStateFunction: (req, callback) =>
req.query.state === req.session.state
? callback()
: callback(new Error('State mismatch')),
});
});
Validation checkpoint: Confirm callbackUri matches a registered redirect URI at the auth server (RFC 6749 §3.1.2).
D2. JWT Validation Middleware
import { FastifyRequest, FastifyReply } from 'fastify';
import jwt from '@fastify/jwt';
export async function verifyToken(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const payload = request.user as Record<string, unknown>;
const now = Math.floor(Date.now() / 1000);
if (typeof payload.exp === 'number' && payload.exp < now)
return reply.code(401).send({ error: 'token_expired' });
if (payload.iss !== process.env.EXPECTED_ISSUER)
return reply.code(401).send({ error: 'invalid_issuer' });
if (payload.aud !== process.env.EXPECTED_AUDIENCE)
return reply.code(401).send({ error: 'invalid_audience' });
} catch (err) {
return reply.code(401).send({ error: 'invalid_token' });
}
}
Validate exp, iss, aud, and sub on every request (RFC 7519 §4). Use asymmetric signing (RS256/ES256 via JWKS) for third-party tokens.
D3. Refresh Token Rotation
async function refreshAccessToken(fastify, refreshToken: string) {
const newToken = await fastify.oauth2.getNewAccessTokenUsingRefreshTokenFlow({
refresh_token: refreshToken,
});
// Replace stored refresh token on every use (RFC 6749 §10.4)
return {
accessToken: newToken.token.access_token,
refreshToken: newToken.token.refresh_token ?? refreshToken,
};
}
D4. Security Checklist
- Validate redirect URI against allowlist (RFC 6749 §3.1.2)
- PKCE S256 for all public clients (RFC 7636 §4.2)
- Validate
stateto prevent CSRF (RFC 6749 §10.12) - Validate
iss,aud,expon every JWT (RFC 7519 §4) - Rotate refresh tokens on every use (RFC 6749 §10.4)
- HTTPS everywhere; reject HTTP redirect URIs (RFC 6749 §3.1.2.1)
- Rate-limit token endpoints (OAuth 2.1 §7)
D5. Anti-Patterns to Avoid
- Storing tokens in localStorage — use HttpOnly, Secure, SameSite=Strict cookies
- Skipping audience validation — allows token reuse across services
- Using implicit flow — deprecated in OAuth 2.1; use authorization code + PKCE
- Symmetric signing (HS256) for third-party tokens — use RS256/ES256 with JWKS
Section E: Advanced TypeScript Types
E1. Eliminating any
Before — raw any:
function getProperty(obj: any, key: string): any { return obj[key]; }
After — generic constraint:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
// getProperty({ name: "Alice" }, "name") → inferred as string ✓
Narrowing an unknown API response:
interface User { id: number; name: string; }
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'id' in value && 'name' in value;
}
async function fetchUser(): Promise<User> {
const res = await fetch('/api/user');
const data: unknown = await res.json();
if (!isUser(data)) throw new Error('Invalid user shape');
return data;
}
E2. Conditional Types & infer
// Extract the resolved type from a Promise
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type Result = Unwrap<Promise<string>>; // string
// Extract array element type
type Element<T> = T extends (infer U)[] ? U : never;
type Elem = Element<string[]>; // string
// Function return type (like ReturnType)
type MyReturn<T> = T extends (...args: any[]) => infer R ? R : never;
E3. Template Literal Types
type EventName<T extends string> = `${T}Changed`;
type UserEvent = EventName<'user'>; // "userChanged"
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiPath = `/api/${string}`;
type Route = `[${HttpMethod}] ${ApiPath}`;
// "[GET] /api/users"
E4. Mapped Types
type Readonly<T> = { readonly [K in keyof T]: T[K]; };
type Optional<T> = { [K in keyof T]?: T[K]; };
type Nullable<T> = { [K in keyof T]: T[K] | null; };
// Deep partial
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
E5. Branded/Opaque Types
// Type-safe identifiers — prevents mixing up UserId and OrderId
type Brand<T, B> = T & { __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
function getUser(id: UserId): User { /* ... */ }
const userId = 'abc123' as UserId;
const orderId = 'xyz789' as OrderId;
getUser(userId); // ✓ OK
getUser(orderId); // ✗ Type error — can't pass OrderId where UserId is expected
E6. Type Workflow
When facing TypeScript errors:
- Run
tsc --noEmitto capture full error output - Identify root cause (unsound inference, missing constraints, implicit
any) - Craft precise, type-safe solutions
- Eliminate all
anytypes — validate each replacement satisfies call sites - Confirm with a second
tsc --noEmitpass
Pitfalls
- Type stripping only works with Node 22.6+. Check the Node.js version before using
--experimental-strip-types. On older versions, fall back totsxorts-node. import typeis mandatory for type stripping. Usingimportfor type-only imports will try to resolve the module at runtime and fail.enumandnamespacedon't work with type stripping. Useconstobjects and plain modules instead.- Streams: pipelines are lazy. Without
await pipeline(...)the stream never runs. Always await or return the promise. - Fastify encapsulation hides decorators from sibling plugins. Use
fastify-pluginto share functionality across plugins. - Never skip the rebuild step when contributing to Node.js core. After editing
src/orlib/, changes don't take effect untilmake -j$(nproc)runs. - Never commit without
-sin Node.js core. Every commit must beSigned-off-by(DCO). - OAuth state parameter must be cryptographically random and session-bound. Using a fixed string or time-based seed breaks CSRF protection.
anytypes propagate silently. Oneanyin a chain disables type checking for everything it touches. Always removeanyfrom the source, not just the call site.- Symmetric signing (HS256) with third-party OAuth providers is a security risk. Only use asymmetric (RS256/ES256) and validate against a JWKS endpoint.
Verification
Before shipping code produced with this skill:
| Check | Section | How |
|---|---|---|
tsc --noEmit passes |
A1, E | Run TypeScript compiler |
node --test passes |
A6 | Run test suite |
| JWT validation covers exp/iss/aud | D2 | Verify hook/middleware claims check |
| Graceful shutdown handles SIGTERM | A5 | Send SIGTERM and confirm cleanup |
| Stream pipeline is awaited | A3 | Check for await pipeline(...) |
All any types removed |
E1, E6 | tsc --noEmit --strict shows 0 errors |
Fastify plugins use fastify-plugin |
B2 | Inspect shared decorators |
| No OAuth implicit flow | D4 | Confirm PKCE+authorization code |
| npm audit clean | A0 | npm audit |