TypeScript Basics: Why and How to Get Started (2026)
TypeScript isn't just "JavaScript with types." It's a different way of thinking about code.
Why TypeScript in 2026?
The #1 reason I use TypeScript:
Catches bugs BEFORE they reach production.
Real examples from my projects:
→ "Cannot read property 'map' of undefined" → TS catches at compile time
→ Passed string where number expected → TS refuses to compile
→ Misspelled property name → TS tells you immediately
→ Forgot to handle null return → TS warns you
The tradeoff:
+ Fewer runtime bugs
+ Better IDE autocomplete
+ Self-documenting code
- Slightly more verbose
- Build step required
- Learning curve for advanced types
Getting Started
Installation & Setup
# Initialize a new project
npm init -y
npm install typescript --save-dev
npx tsc --init
# Or create an Express + TypeScript project quickly
npm init -y
npm install express
npm install -D typescript @types/express @types/node ts-node nodemon
npx tsc --init
tsconfig.json Essentials
{
"compilerOptions": {
"target": "ES2022", // Output JS version
"module": "NodeNext", // Modern module resolution
"moduleResolution": "NodeNext",
"outDir": "./dist", // Compiled output directory
"rootDir": "./src", // Source directory
"strict": true, // Enable all strict checks (IMPORTANT!)
"esModuleInterop": true, // Interop with CommonJS
"skipLibCheck": true, // Skip type checking .d.ts files (faster)
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true, // Generate .d.ts files for libraries
"sourceMap": true, // Generate source maps for debugging
"noUnusedLocals": true, // Error on unused variables
"noUnusedParameters": true, // Error on unused parameters
"noImplicitReturns": true, // Error if function might not return value
"noFallthroughCasesInSwitch": true // Error on switch fallthrough
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
package.json Scripts
{
"scripts": {
"build": "tsc",
"dev": "ts-node-esm src/server.ts",
"dev:watch": "nodemon --exec ts-node-esm src/server.ts",
"start": "node dist/server.js",
"typecheck": "tsc --noEmit"
}
}
Core Type System
Basic Types
// Primitive types
let isActive: boolean = true;
let count: number = 42;
let name: string = "Alice";
let nothing: null = null;
let notDefined: undefined = undefined;
// bigint and symbol (rarely used but exist)
let bigNum: bigint = 9007199254740991n;
let sym: symbol = Symbol("unique");
// Arrays
const numbers: number[] = [1, 2, 3];
const strings: Array<string> = ["a", "b", "c"];
// Tuples (fixed-length arrays with known types)
let user: [string, number] = ["Alice", 30];
// user = [30, "Alice"]; // ERROR — wrong order!
// Any (avoid when possible!)
let risky: any = "could be anything";
risky = 42; // No error
risky.foo(); // No error — defeats the purpose of TS!
// Unknown (safer alternative to any)
let safer: unknown = "could be anything";
// safer.foo(); // ERROR — must check type first
if (typeof safer === "string") {
console.log(safer.toUpperCase()); // OK — narrowed to string
}
// Void (functions that don't return)
function log(message: string): void {
console.log(message);
// No return needed
}
// Never (functions that never complete)
function fail(message: string): never {
throw new Error(message); // Never returns
}
Objects & Interfaces
// Interface — best for object shapes
interface User {
id: string;
email: string;
name: string;
role: 'user' | 'admin' | 'moderator';
createdAt: Date;
updatedAt?: Date; // Optional field (?)
readonly internalId: number; // Read-only
}
// Usage
const user: User = {
id: 'usr_123',
email: 'alice@example.com',
name: 'Alice',
role: 'admin',
createdAt: new Date(),
internalId: 9001,
};
// user.internalId = 9999; // ERROR — readonly!
// user.role = 'superadmin'; // ERROR — not in union type!
// Type alias — more flexible than interface
type ID = string;
type Status = 'pending' | 'active' | 'inactive' | 'suspended';
type UserWithStatus = User & { status: Status }; // Intersection
// When to use which:
// → Interface: object shapes, classes implementing them
// → Type alias: unions, intersections, computed types, primitives
Functions
// Full type annotation
function add(a: number, b: number): number {
return a + b;
}
// Return type inferred (preferred when obvious)
function greet(name: string) {
return `Hello, ${name}!`; // Inferred as string
}
// Default parameters
function createUser(name: string, role: string = 'user') {
return { name, role };
}
// Optional parameters
function findUser(id: string, includeDeleted?: boolean) {
// includeDeleted is boolean | undefined
}
// Rest parameters
function sum(...numbers: number[]): number {
return numbers.reduce((total, n) => total + n, 0);
}
sum(1, 2, 3, 4, 5); // 15
// Function as type
type FilterFn<T> = (item: T, index: number) => boolean;
function filter<T>(items: T[], predicate: FilterFn<T>): T[] {
return items.filter(predicate);
}
// Callback typing
function fetchData(
url: string,
onSuccess: (data: unknown) => void,
onError?: (error: Error) => void
): void { /* ... */ }
// Async functions always return Promise<T>
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json(); // Inferred as Promise<User>
}
Practical Examples
Express Server with TypeScript
import express, { Request, Response, NextFunction } from 'express';
import helmet from 'helmet';
import cors from 'cors';
// Extend Express Request to include custom properties
declare global {
namespace Express {
interface Request {
user?: AuthenticatedUser;
requestId?: string;
}
}
}
interface AuthenticatedUser {
id: string;
email: string;
role: 'user' | 'admin';
}
const app = express();
app.use(helmet());
app.use(cors());
app.use(express.json());
// Typed middleware
function auth(req: Request, _res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
throw new UnauthorizedError('Missing token');
}
const payload = verifyToken(header.slice(7));
req.user = payload; // Works because we extended the Request type
next();
}
// Typed route handlers
app.get('/api/users/:id', auth, async (req: Request, res: Response) => {
const user = await userService.findById(req.params.id);
res.json(user);
});
// Generic API response helper
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: { code: string; message: string };
}
function ok<T>(res: Response, data: T, status = 200) {
return res.status(status).json<ApiResponse<T>>({ success: true, data });
}
function fail(res: Response, code: string, message: string, status = 400) {
return res.status(status).json<ApiResponse<null>>({
success: false,
error: { code, message },
});
}
// Usage
ok(res, user); // infers data type from user
fail(res, 'NOT_FOUND', 'User'); // data is null
Database Models
// Type-safe database operations
interface BaseEntity {
id: string;
createdAt: Date;
updatedAt: Date;
}
interface User extends BaseEntity {
email: string;
name: string;
passwordHash: string;
role: UserRole;
}
type UserRole = 'user' | 'admin' | 'moderator';
interface CreateUserData {
email: string;
name: string;
password: string;
role?: UserRole; // defaults to 'user'
}
class UserService {
async create(data: CreateUserData): Promise<User> {
const passwordHash = await hashPassword(data.password);
const user = await db.query(
'INSERT INTO users (email, name, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING *',
[data.email, data.name, passwordHash, data.role || 'user']
);
return user.rows[0]; // Type-safe access
}
async findById(id: string): Promise<User | null> {
const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
return result.rows[0] ?? null; // Proper null handling
}
}
Utility Types (Built-in)
// Partial<T> — make all fields optional
interface Todo { title: string; done: boolean; priority: number; }
function updateTodo(id: string, updates: Partial<Todo>) {
// updates can be any subset of Todo fields
}
updateTodo('1', { done: true }); // Only update done field
// Required<T> — make all fields required (inverse of Partial)
// Omit<T, K> — remove specific keys
type TodoInput = Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>;
// Pick<T, K> — keep only specific keys
type TodoPreview = Pick<Todo, 'title' | 'done'>;
// Readonly<T> — make all fields read-only
const config: Readonly<AppConfig> = { ... };
// Record<K, V> — key-value mapping
type RolePermissions = Record<UserRole, string[]>;
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
user: ['read'],
moderator: ['read', 'write'],
};
// Extract type from array
const statuses = ['pending', 'active', 'inactive'] as const;
type Status = typeof statuses[number]; // 'pending' | 'active' | 'inactive'
// Awaited<T> — unwrap Promise type
type UserData = Awaited<ReturnType<typeof userService.findById>>;
// Same as User | null
Error Handling with TypeScript
// Custom error class hierarchy
class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: unknown
) {
super(message);
this.name = this.constructor.name;
}
}
class NotFoundError extends AppError {
constructor(resource: string) {
super(404, 'NOT_FOUND', `${resource} not found`);
}
}
class ValidationError extends AppError {
constructor(public fields: Array<{ field: string; issue: string }>) {
super(422, 'VALIDATION_ERROR', 'Validation failed', fields);
}
}
// Type-narrowing error handler
function isError(error: unknown): error is Error {
return error instanceof Error;
}
function isAppError(error: unknown): error is AppError {
return error instanceof AppError;
}
// Safe error handler middleware
function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction) {
if (isAppError(err)) {
return res.status(err.statusCode).json({
error: { code: err.code, message: err.message, details: err.details }
});
}
if (isError(err)) {
console.error('Unexpected error:', err.stack);
return res.status(500).json({
error: { code: 'INTERNAL_ERROR', message: 'An internal error occurred' }
});
}
// Handle non-Error throws (strings, numbers, etc.)
console.error('Unknown error:', err);
res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'Something went wrong' } });
}
Migration Strategy: JS → TypeScript
Don't rewrite everything at once!
Step 1: Add tsconfig.json with strict: false initially
Step 2: Rename .js files to .ts one by one (start with entry point)
Step 3: Fix errors file by file
Step 4: Enable strict mode gradually:
→ noImplicitAny first
→ strictNullChecks next
→ noUnusedLocals/Params
→ Finally: strict: true
Each file you convert makes your codebase safer.
Even partial TypeScript is better than none.
Quick Reference
| Concept | Syntax |
|---|---|
| Basic annotation | let x: number = 5 |
| Array |
number[] or Array<number>
|
| Optional |
field?: string or `string \ |
| Union | {% raw %}`string \ |
| Literal type | {% raw %}`'success' \ |
| Interface | {% raw %}interface Name { ... }
|
| Type alias | type Name = { ... } |
| Generic | function fn<T>(arg: T): T |
| Partial | Partial<User> |
| Omit/Pick | Omit<User, 'id'> |
| Readonly | Readonly<Config> |
| Assert type | value as string |
| Non-null assert |
value! (use sparingly!) |
| Satisfies | const obj = {...} satisfies Shape |
Are you team JavaScript or team TypeScript?
Follow @armorbreak for more practical developer guides.
Top comments (0)