If you already write JavaScript, you know most of TypeScript. The language is the same. What changes is that you describe the shape of your data, and a compiler checks that you use it correctly before the code runs. This primer covers the mental model first, then the features you will use daily, then the mistakes that trip people up.
The mental model
Think of TypeScript as a spell checker for data shapes. A spell checker does not change what you write, and it does not exist at the moment someone reads it. It only flags mistakes while you are writing.
That is exactly how TypeScript behaves:
- Types exist only at compile time. They are erased, and the output is plain JavaScript.
- The compiler cannot see runtime data. An API response typed as
Useris a promise you made, not a guarantee. - Errors are advice from the compiler, not a runtime safety net.
Keep the last two points in mind. Most TypeScript bugs in production come from forgetting that types vanish at runtime.
Setup in two minutes
npm install -D typescript
npx tsc --init
In tsconfig.json, turn on strict mode and leave it on:
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext"
}
}
Trade-off: strict produces more errors on day one. Without it, TypeScript allows enough loose behavior that you lose most of the benefit. Start strict, because loosening later is easier than tightening a large codebase.
Basic types and inference
You annotate variables with a colon and a type:
const name: string = "Asha";
const age: number = 24;
const isActive: boolean = true;
const scores: number[] = [90, 85, 77];
You rarely need to write these. TypeScript infers types from values:
const city = "Pune"; // inferred as "Pune"
let count = 0; // inferred as number
count = "five"; // Error: string is not assignable to number
Rule of thumb: annotate function parameters and public return types. Let inference handle local variables.
Describing objects: type and interface
Both describe the shape of an object.
interface User {
id: number;
name: string;
email?: string; // optional
}
type Product = {
id: number;
title: string;
price: number;
};
The practical difference is small. interface can be extended and merged across declarations. type can also describe unions and primitives. Pick one convention per project and stay consistent. A reasonable default: interface for object shapes, type for everything else.
Union types and narrowing
A union says a value can be one of several types:
type Id = string | number;
function printId(id: Id) {
if (typeof id === "string") {
console.log(id.toUpperCase()); // id is string here
} else {
console.log(id.toFixed(0)); // id is number here
}
}
The compiler tracks the type through your if checks. This is called narrowing, and it is the feature that makes TypeScript feel intelligent rather than bureaucratic.
Literal types
You can restrict a value to exact options:
type Status = "idle" | "loading" | "success" | "error";
let status: Status = "loading";
status = "done"; // Error
This replaces many bugs caused by typos in strings.
Discriminated unions
Combine literal types with objects for safe state modelling:
type Result =
| { status: "success"; data: string[] }
| { status: "error"; message: string };
function handle(result: Result) {
if (result.status === "success") {
console.log(result.data); // data exists here
} else {
console.log(result.message); // message exists here
}
}
Compare this with a single object that has optional data and optional message. That version allows impossible states, such as both being present. The union makes impossible states unrepresentable.
Functions
function add(a: number, b: number): number {
return a + b;
}
const greet = (name: string, greeting = "Hello"): string =>
`${greeting}, ${name}`;
For callbacks, describe the function type:
type Predicate<T> = (item: T) => boolean;
Generics
Generics let you write reusable code that stays type safe. Picture a labeled storage box: the box works for any content, but once you label it “books”, only books should go in.
function first<T>(items: T[]): T | undefined {
return items[0];
}
const n = first([1, 2, 3]); // number | undefined
const s = first(["a", "b"]); // string | undefined
You will mostly consume generics rather than write them: Array<string>, Promise<User>, Record<string, number>.
Failure mode: over-engineering. If a generic needs three type parameters and a conditional type, a simpler design usually exists.
unknown versus any
any turns off type checking for a value. It spreads: anything derived from an any is also unchecked.
unknown means “I do not know the type yet, so prove it before use”:
function parse(input: unknown) {
if (typeof input === "string") {
return input.trim(); // safe, narrowed to string
}
throw new Error("Expected string");
}
Use unknown for data from outside your program. Reserve any for rare, deliberate escapes, and treat each one as debt.
Useful utility types
TypeScript ships helpers that transform existing types:
interface User {
id: number;
name: string;
email: string;
}
type UserPreview = Pick<User, "id" | "name">;
type UserUpdate = Partial<User>; // all fields optional
type PublicUser = Omit<User, "email">;
type UserMap = Record<number, User>;
type ReadonlyUser = Readonly<User>;
These keep one source of truth. Change User once and the derived types follow.
Null handling
With strict on, null and undefined are not silently allowed everywhere. The compiler forces you to deal with them:
function getLength(text?: string) {
return text?.length ?? 0; // optional chaining + nullish coalescing
}
Avoid the non-null assertion operator (value!). It silences the compiler without making the code safer. If the value is null at runtime, you get the same crash you had in JavaScript.
Types do not validate runtime data
This is the most important failure mode in the whole article.
const res = await fetch("/api/user");
const user = (await res.json()) as User; // a claim, not a check
The as User cast tells the compiler to trust you. If the server returns a different shape, nothing stops it, and the bug appears later, far from its cause.
At system boundaries (API responses, form input, environment variables, JSON.parse), validate with a runtime schema library such as Zod, then derive the type from the schema. That gives you one definition that checks data at runtime and types it at compile time.
Common mistakes
- Using
anyto make errors disappear. The error was information. Fix the type or useunknown. - Casting with
asinstead of narrowing. Casts bypass checks. Narrowing proves the claim. - Annotating everything. Redundant annotations add noise and drift out of date.
- Treating types as validation. They are erased before the code runs.
- Disabling
strictto move faster. You trade a small amount of friction now for a large amount of ambiguity later.
A sensible learning path
- Convert one small JavaScript file to
.tswithstricton. - Type your function parameters and let inference do the rest.
- Model one real piece of state with a discriminated union.
- Add Zod validation to one API call.
- Read the compiler errors slowly. They are the best teacher.
Wrap-up
TypeScript is JavaScript plus a checker that understands your data shapes. Learn inference, unions, narrowing, and a few utility types, and you cover most daily use. Remember that types disappear at runtime, so validate anything that crosses a boundary. Start strict, avoid any, and let the compiler argue with you early rather than users arguing with you later.