TypeScript Basics: Why You Should Switch
You rename a field from userName to name in your API response, fix the three places you remember, and ship. Two days later a bug report comes in: the profile page shows "Hello, undefined". Or you get TypeError: Cannot read properties of undefined (reading 'map') in production because an endpoint returned null instead of an empty array. Plain JavaScript lets all of this through until someone actually runs the code.
TypeScript moves a big chunk of those mistakes from "a user found it" to "my editor underlined it". This post covers what TypeScript actually adds on top of JavaScript, the core types you'll use every day, how to set it up in a project, and the mistakes I see most often when people start using it.
TypeScript checks your code before it runs. You get red squiggles for wrong arguments, missing properties and possible undefined values while you type, plus autocomplete that knows the shape of your data.
What is TypeScript?
Take your JavaScript, add type annotations where you want them, and you have TypeScript. Microsoft maintains it, and it's a superset of JavaScript: any valid JavaScript is also valid TypeScript. On top of that you get optional static types, interfaces, generics and a compiler that checks them.
The types only exist at build time. The compiler strips them out and emits plain JavaScript, so the result runs anywhere JavaScript runs: browsers, Node.js, Deno, Bun.
TypeScript compiles to JavaScript while catching type errors during compilation
JavaScript vs TypeScript: A Quick Comparison
Here's the same function in both languages. The only change is the type annotations:
JavaScript
function greet(name) {
return "Hello, " + name;
}
// No error - but this is wrong!
greet(42);
greet({ name: "John" });
greet();
TypeScript
function greet(name: string): string {
return "Hello, " + name;
}
// ❌ Error: Argument of type
// 'number' is not assignable
greet(42);
// ✅ This works!
greet("John");
The JavaScript version accepts anything: a number, an object, or no argument at all. You find out when the output looks wrong. The TypeScript version refuses to compile the bad call, and your editor shows the error as soon as you type it.
Why You Should Switch to TypeScript
These are the things that actually change day to day once a project is typed:
Catch Bugs Before Runtime
Type errors show up while you're writing the code, not when a user hits them. A 2017 academic study ("To Type or Not to Type", Gao, Bird and Barr) looked at real bugs in public JavaScript projects and found that type annotations would have caught roughly 15% of them. That's not everything, but it's a lot of bugs you never have to debug.
Autocomplete That Knows Your Data
Your editor can suggest the real properties of an object, show function signatures and pull in doc comments. You stop opening three files to remember whether it was user.id or user.userId.
Safer Refactoring
Rename a property or change a function signature and the compiler lists every place that breaks. That's exactly the "renamed field" bug from the intro, caught before you commit.
Self-Documenting Code
A function signature tells you what goes in and what comes out, and unlike a comment it can't go stale without the build failing. New people on a project can read the types instead of guessing.
Better Team Collaboration
Types act as contracts between parts of the code. If the backend and frontend share a User type, there's far less back and forth about which fields an endpoint returns.
Core TypeScript Concepts
You can get a long way with just these six ideas:
Basic Types
string, number, boolean, null, undefined, any
Arrays & Tuples
Typed arrays and fixed-length tuples
Interfaces
Define object shapes and contracts
Type Aliases
Create custom type names
Union Types
One of several types
Generics
Reusable typed components
Basic Types
These are the types you'll write constantly:
// Primitive types
let username: string = "john_doe";
let age: number = 25;
let isActive: boolean = true;
// Arrays
let scores: number[] = [85, 90, 78];
let names: Array<string> = ["Alice", "Bob"];
// Tuple (fixed-length array with specific types)
let user: [string, number] = ["John", 30];
// Enum
enum Status {
Pending = "PENDING",
Active = "ACTIVE",
Completed = "COMPLETED"
}
let orderStatus: Status = Status.Active;
// Any (escape hatch - use sparingly!)
let flexible: any = "hello";
flexible = 42; // No error
// Unknown (safer than any)
let uncertain: unknown = "hello";
// uncertain.toUpperCase(); // Error! Must narrow first
if (typeof uncertain === "string") {
uncertain.toUpperCase(); // OK!
}
// Void (for functions that don't return)
function logMessage(msg: string): void {
console.log(msg);
}
// Never (for functions that never return)
function throwError(message: string): never {
throw new Error(message);
}
Once a value is any, the compiler stops checking it, and anything you derive from it is unchecked too. If you really don't know the type, use unknown: it forces you to narrow the type before you use the value.
Interfaces and Type Aliases
For anything bigger than a primitive, you'll name the shape with an interface or a type alias:
// Interface - defines object shape
interface User {
id: number;
name: string;
email: string;
age?: number; // Optional property
readonly createdAt: Date; // Cannot be modified
}
// Using the interface
const user: User = {
id: 1,
name: "John Doe",
email: "john@example.com",
createdAt: new Date()
};
// Type alias - can define any type
type ID = string | number;
type Status = "pending" | "active" | "completed";
// Type alias for object (similar to interface)
type Product = {
id: ID;
name: string;
price: number;
status: Status;
};
// Extending interfaces
interface Employee extends User {
department: string;
salary: number;
}
// Intersection types (combine types)
type Manager = Employee & {
team: string[];
level: number;
};
- Interfaces can be extended and merged (declaration merging)
- Type aliases are more flexible for unions, tuples, and primitives
- For object shapes, both work. I use interface for public APIs
- Use type for unions, intersections, and computed types
Union and Intersection Types
A union says "one of these", an intersection says "all of these at once":
// Union Type: value can be one of several types
type StringOrNumber = string | number;
function printId(id: StringOrNumber) {
if (typeof id === "string") {
console.log(id.toUpperCase()); // TypeScript knows it's string here
} else {
console.log(id.toFixed(2)); // TypeScript knows it's number here
}
}
printId("abc123"); // OK
printId(42); // OK
// Literal Union Types
type Direction = "north" | "south" | "east" | "west";
type HttpStatus = 200 | 201 | 400 | 404 | 500;
function move(direction: Direction) {
console.log(`Moving ${direction}`);
}
move("north"); // OK
// move("up"); // Error: not in union
// Intersection Type: combines multiple types
type HasName = { name: string };
type HasAge = { age: number };
type Person = HasName & HasAge;
const person: Person = {
name: "John",
age: 30
// Must have BOTH properties
};
Generics
Generics let one function or class work with many types without falling back to any. You'll use them most for things like API response wrappers and collections:
// Generic function
function identity<T>(value: T): T {
return value;
}
const str = identity("hello"); // type: string
const num = identity(42); // type: number
// Generic with constraints
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length);
}
logLength("hello"); // OK - strings have length
logLength([1, 2, 3]); // OK - arrays have length
// logLength(42); // Error - numbers don't have length
// Generic interface
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface User {
id: number;
name: string;
}
const userResponse: ApiResponse<User> = {
data: { id: 1, name: "John" },
status: 200,
message: "Success"
};
// Generic class
class DataStore<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
get(index: number): T {
return this.items[index];
}
getAll(): T[] {
return [...this.items];
}
}
const numberStore = new DataStore<number>();
numberStore.add(1);
numberStore.add(2);
const stringStore = new DataStore<string>();
stringStore.add("hello");
stringStore.add("world");
T: Type (most common)K: KeyV: ValueE: ElementR: Return type
Getting Started with TypeScript
Setting it up in a plain Node project takes four steps. Frameworks usually do most of this for you (see the next section).
Install TypeScript
Install TypeScript globally or as a dev dependency in your project
# Install globally
npm install -g typescript
# Or as dev dependency (recommended)
npm install --save-dev typescript
# Check version
tsc --version
Initialize Configuration
Create a tsconfig.json file to configure the TypeScript compiler
# Generate tsconfig.json with defaults
tsc --init
Configure tsconfig.json
Adjust the configuration for your project needs
{
"compilerOptions": {
// Target JavaScript version
"target": "ES2020",
// Module system
"module": "ESNext",
"moduleResolution": "node",
// Output directory
"outDir": "./dist",
"rootDir": "./src",
// Strict type checking (recommended!)
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
// Additional checks
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
// Allow JavaScript files
"allowJs": true,
// Source maps for debugging
"sourceMap": true,
// Faster builds
"skipLibCheck": true,
// Interop with CommonJS
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Write and Compile
Create TypeScript files and compile them to JavaScript
# Compile once
tsc
# Watch mode (recompile on changes)
tsc --watch
# Or use package.json scripts
npm run build # tsc
npm run dev # tsc --watch
TypeScript with Popular Frameworks
Most current frameworks support TypeScript out of the box, so you rarely write the config by hand:
React with TypeScript
# Create new React app with TypeScript
npx create-react-app my-app --template typescript
# Add TypeScript to existing React app
npm install --save-dev typescript @types/react @types/react-dom
Heads up: Create React App was officially deprecated in early 2025. For a new project I'd use npm create vite@latest my-app -- --template react-ts or a framework like Next.js instead.
Example Component:
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
}
const Button: React.FC<ButtonProps> = ({ label, onClick, disabled }) => {
return (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
);
};
Next.js with TypeScript
# Create new Next.js app with TypeScript
npx create-next-app@latest my-app --typescript
# Add TypeScript to existing Next.js app
touch tsconfig.json
npm install --save-dev typescript @types/react @types/node
npm run dev # Next.js auto-configures TypeScript
Example Page:
import { GetServerSideProps, NextPage } from 'next';
interface Props {
users: User[];
}
const UsersPage: NextPage<Props> = ({ users }) => {
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
};
export const getServerSideProps: GetServerSideProps<Props> = async () => {
const users = await fetchUsers();
return { props: { users } };
};
Node.js with TypeScript
# Initialize and install
npm init -y
npm install --save-dev typescript @types/node ts-node nodemon
# Create tsconfig.json
npx tsc --init
Example Express Server:
import express, { Request, Response } from 'express';
const app = express();
interface User {
id: number;
name: string;
}
app.get('/users/:id', (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
const user: User = { id: userId, name: 'John' };
res.json(user);
});
app.listen(3000, () => console.log('Server running'));
JavaScript vs TypeScript Comparison
| Feature | JavaScript | TypeScript |
|---|---|---|
| Type System | Dynamic (runtime) | Static (compile-time) |
| Error Detection | At runtime | During development |
| IDE Support | Basic | Excellent (autocomplete, refactoring) |
| Learning Curve | Lower | Slightly higher |
| Code Documentation | Manual (JSDoc) | Built-in via types |
| Refactoring Safety | Risky | Safe with compiler checks |
| Build Step | Optional | Required (compilation) |
| Ecosystem | Huge | Huge (same as JS + @types) |
TypeScript Best Practices
1. Enable Strict Mode
Turn on "strict": true in tsconfig.json from day one. It enables the whole family of strict checks, including strictNullChecks, which is where most of the real bugs get caught. Turning it on later in a big codebase means fixing hundreds of errors at once.
// tsconfig.json
{
"compilerOptions": {
"strict": true // Enables all strict checks
}
}
2. Prefer 'unknown' Over 'any'
When you don't know the type, use unknown instead of any. It forces you to perform type checking before using the value.
// Bad
function processData(data: any) {
return data.toUpperCase(); // No error, but might crash!
}
// Good
function processData(data: unknown) {
if (typeof data === "string") {
return data.toUpperCase(); // Safe!
}
throw new Error("Expected string");
}
3. Use Type Inference
You don't need to annotate everything. The compiler infers types for variables just fine, so save annotations for function parameters and return types, where they act as the contract.
// Verbose (unnecessary)
const name: string = "John";
const numbers: number[] = [1, 2, 3];
// Clean (TypeScript infers the types)
const name = "John"; // inferred as string
const numbers = [1, 2, 3]; // inferred as number[]
// DO add types for function parameters and return types
function add(a: number, b: number): number {
return a + b;
}
4. Use Utility Types
Before writing a near-copy of an existing type, check whether a built-in utility type already does it:
interface User {
id: number;
name: string;
email: string;
password: string;
}
// Partial - makes all properties optional
type UserUpdate = Partial<User>;
// Pick - select specific properties
type UserPreview = Pick<User, "id" | "name">;
// Omit - exclude specific properties
type SafeUser = Omit<User, "password">;
// Required - makes all properties required
type RequiredUser = Required<Partial<User>>;
// Readonly - makes all properties readonly
type FrozenUser = Readonly<User>;
// Record - create object type with specific keys
type UserRoles = Record<"admin" | "user" | "guest", string[]>;
Common TypeScript Mistakes to Avoid
const data: any = fetchData(); // Defeats TypeScript's purpose!
interface ApiData { id: number; name: string; }
const data: ApiData = await fetchData();
const user = users.find(u => u.id === id);
console.log(user.name); // Error if user is undefined!
const user = users.find(u => u.id === id);
if (user) { console.log(user.name); }
// Or: console.log(user?.name ?? "Unknown");
const input = document.getElementById("email") as HTMLInputElement;
input.value = "test"; // Might crash if element doesn't exist!
const input = document.getElementById("email");
if (input instanceof HTMLInputElement) {
input.value = "test"; // Safe!
}
Two errors you'll hit in your first week, usually right after installing a package:
Could not find a declaration file for module 'some-lib'(TS7016): the package ships plain JavaScript with no types. Trynpm install --save-dev @types/some-lib. If nothing exists, add adeclare module 'some-lib';line in a.d.tsfile as a stopgap.- Types don't exist at runtime.
const data = (await res.json()) as Usercompiles fine even if the API sends back something completely different. For data coming from outside your code (API responses, form input,JSON.parse), validate it at runtime with a schema library like Zod, or with your own type guards.
When I'd Use TypeScript (and When I Wouldn't)
For anything that more than one person touches, or that I'll still be maintaining in six months, I reach for TypeScript by default. That covers most React and Next.js frontends, Node APIs and shared libraries. The bigger the codebase, the more the refactoring safety pays off.
I'd skip it, or hold off, in a few cases:
- Small throwaway scripts and one-off prototypes where a build step costs more time than the bugs it would catch.
- A team that's already under deadline pressure and new to TypeScript. You'll end up with
anyeverywhere, which is the build overhead without the benefit. Learn it on a calmer project first. - Code that is mostly dynamic data shapes, like heavy metaprogramming. Typing it properly can take longer than writing it, and JSDoc comments with
// @ts-checkmight be a better middle ground.
Also be honest about the costs: there's a compile step, CI gets slower on large projects, and some advanced generic types are hard to read. None of that outweighs the benefits for a real application, but it's not free.
You don't have to convert everything at once. Set allowJs: true so .js and .ts files can live side by side, write new files in TypeScript, and convert old ones as you touch them. Start with the files that model your data (API clients, shared types), because everything else benefits from those first.