This article is published in English.
TypeScript Fundamentals: From First Annotation to Generics and Strict Mode
A structured walkthrough of TypeScript's type system, from primitives and inference to discriminated unions, generics, utility types and a strict tsconfig.
Every JavaScript developer knows the pattern: code works locally, ships, and two days later a bug report arrives because a function got an object instead of a string, or undefined slipped into a calculation. JavaScript does not stop you from writing that code; it simply fails later, at runtime, usually in production. TypeScript moves that failure to the moment you type the line. This guide takes you from a first let x: number through narrowing, discriminated unions, generics and utility types to the compiler settings that decide how much protection you actually get, so you can read and write production TypeScript with confidence.
What TypeScript is, and what it is not
TypeScript is an open-source language from Microsoft that adds a static type system to JavaScript. Here "static" means the compiler verifies types ahead of execution, in your editor or at compile time, rather than discovered during execution. Three facts shape everything else:
- It is a superset of JavaScript. Any syntactically valid JavaScript is also valid TypeScript syntax, so you are extending what you already know rather than starting over. The checker may still report errors on such code, which is exactly the point.
- It compiles to plain JavaScript. Browsers and Node.js run JavaScript, so the compiler,
tsc, removes the annotations and emits ordinary.jsfiles. - Types vanish at runtime. They exist to protect you while developing. Unlike Java, where type information survives into compiled bytecode, nothing about a TypeScript type exists once the program is running.
The difference in one function
Here is an ordinary JavaScript function that adds two values:
function add(a, b) {
return a + b;
}
Called with a string and a number, it does not throw. It concatenates, and you only notice when a total on an invoice looks odd:
add("10", 20); // "1020" — silently wrong
Adding parameter and return types states the contract explicitly:
function add(a: number, b: number): number {
return a + b;
}
Now the same call is rejected while you are still editing, with the error shown directly under the offending argument:
add("10", 20);
// Error: Argument of type 'string' is not assignable to parameter of type 'number'.
Setting up a project
With Node.js installed, you can install the compiler globally and confirm it works:
npm install -g typescript
tsc --version
For real projects, install TypeScript as a local development dependency so every contributor and the CI server use the same version, then generate a configuration file:
npm install typescript --save-dev
npx tsc --init
The generated tsconfig.json is the control panel for how strict and how modern the compiler should be. Recent versions of tsc --init already enable strict, and TypeScript 6.0 made strict mode the default, but older projects often run with lenient settings that teams later tighten. The configuration section below returns to this.
A minimal first file declares a typed variable and logs it:
// app.ts
let message: string = "Hello, TypeScript";
console.log(message);
Compile it, then run the emitted JavaScript:
tsc app.ts # produces app.js
node app.js # Hello, TypeScript
Day to day, most projects avoid this two-step flow and run files through ts-node or tsx, or rely on a bundler such as Vite, esbuild or webpack that compiles on the fly. Doing it by hand once is still worthwhile, because it fixes the right mental model: TypeScript goes in, JavaScript comes out.
Basic types and inference
Primitives and arrays
The primitive annotations are string, number and boolean:
let username: string = "sanajit";
let age: number = 26;
let isActive: boolean = true;
Arrays are written with the element type followed by brackets:
let scores: number[] = [90, 85, 78];
let tags: string[] = ["typescript", "javascript"];
The generic form Array<number> means exactly the same thing; pick one style and use it consistently:
// equivalent generic syntax
let ids: Array<number> = [1, 2, 3];
Let inference do the obvious work
You rarely need to annotate initialized variables. TypeScript infers the type from the value and then enforces it:
let city = "Ahmedabad"; // inferred as string
city = 42; // Error: Type 'number' is not assignable to type 'string'
Writing let city: string = "Ahmedabad" is not wrong, just redundant. A good rule for the whole language: rely on inference when the value makes the type obvious, and write explicit types where a value is ambiguous or where you are defining a contract, such as function parameters, return types, empty arrays and callback signatures.
any, unknown, never and void
These four special types trip up nearly every beginner:
anyswitches type checking off for a value. It is an escape hatch rather than a real type, and overusing it is the most common way a codebase ends up as JavaScript with extra syntax.unknownis the safe counterpart. Anything can be assigned to it, but you cannot use the value until you narrow it with a check. It is the right choice for data whose shape you do not know yet, such as an API response before validation.neverdescribes a value that cannot exist, for example the result of a function that always throws or never returns. Its most practical use is exhaustiveness checking, making the compiler confirm that every case of aswitchis handled.voidmarks a function that returns nothing useful, like an event handler or a logging wrapper.
With unknown, a typeof check unlocks the string methods only inside the guarded branch:
function process(value: unknown) {
if (typeof value === "string") {
console.log(value.toUpperCase()); // safe — narrowed to string
}
}
A function that always throws is typed never, while a function that just performs a side effect returns void:
function fail(message: string): never {
throw new Error(message);
}function logAction(action: string): void {
console.log(`Action: ${action}`);
}
For concrete replacements for any in common situations, see six type-safe patterns for replacing any.
Describing objects with interfaces and type aliases
An object's shape can be written inline:
const employee: { id: number; name: string; active: boolean } = {
id: 1,
name: "Amit",
active: true,
};
That works once, but repeating the same inline shape everywhere quickly becomes noise. Naming the shape with an interface or a type solves this.
Interfaces
An interface names an object shape so you can reuse it:
interface Employee {
id: number;
name: string;
department: string;
}
Every object annotated with it must match the declared fields:
const employee1: Employee = { id: 1, name: "John", department: "IT" };
const employee2: Employee = { id: 2, name: "Sara", department: "HR" };
A question mark marks a property that may be absent:
interface User {
id: number;
name: string;
phone?: string; // may or may not be present
}
readonly properties can be assigned when the object is created but not afterwards:
interface Product {
readonly id: number;
name: string;
}
Trying to reassign one is a compile-time error:
const laptop: Product = { id: 100, name: "MacBook" };
laptop.id = 200; // Error: Cannot assign to 'id' because it is a read-only property
Interfaces can also extend each other, which lets you share common fields without copying them. Start with a base shape:
interface Person {
name: string;
age: number;
}
Then build a more specific one on top of it:
interface Employee extends Person {
employeeId: number;
department: string;
}
Type aliases
A type alias can name object shapes too, but it is not limited to them. Unions, primitives and tuples can all be given a name this way:
type ID = string | number;
type Point = { x: number; y: number };
type Status = "pending" | "shipped" | "delivered";
Choosing between interface and type
For everyday object shapes the two are largely interchangeable. The real differences are:
- Extending: interfaces use
extends; type aliases combine shapes with the&intersection operator. - Unions and primitives: only type aliases can express them, as in
type A = string | number. - Declaration merging: two interfaces with the same name merge into one; type aliases cannot be redeclared.
- Convention: interfaces are typical for public API shapes and class contracts; type aliases for unions, tuples and mapped or conditional types.
A rule many teams adopt: reach for interface whenever a shape is likely to be extended, such as React props, API models and class contracts, and keep type for unions, tuples and every non-object type.
Unions, narrowing and intersections
Union types
A union says a value may be one of several types:
function printId(id: string | number) {
console.log(`Your ID is ${id}`);
}
Both calls below are accepted, because each argument matches one member of the union:
printId(101);
printId("A-204");
Narrowing
When a value is typed as a union, TypeScript only allows operations valid for every member until you prove which member you have. That proof is called narrowing, and the compiler follows it through your control flow:
function formatValue(value: string | number) {
if (typeof value === "string") {
return value.toUpperCase(); // TypeScript knows it's a string here
}
return value.toFixed(2); // and here, it knows it's a number
}
Besides typeof, the common narrowing tools are Array.isArray(), instanceof, the in operator and equality checks against null or undefined. This is how TypeScript stays quiet in the normal case and still flags the unusual one.
Discriminated unions for state
The pattern intermediate developers most often miss is giving every variant of a union a shared literal "tag" field. First, define each state separately:
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState = { status: "error"; message: string };
Then combine them and switch on the tag:
type FetchState = LoadingState | SuccessState | ErrorState;function render(state: FetchState) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return `Loaded ${state.data.length} items`;
case "error":
return `Failed: ${state.message}`;
}
}
Inside each case, state is narrowed to the matching variant, so state.data is only available in the "success" branch and state.message only in "error". Impossible combinations, such as having data and an error at the same time, simply cannot be represented. That removes a whole category of "property of undefined" crashes in UI and API code. Adding a default branch that assigns state to a never variable turns this into an exhaustiveness check: add a fourth state later and the compiler points to every switch that forgot it.
Intersection types
Where a union means "this or that", an intersection means "this and that". Start with two small shapes:
type Timestamped = { createdAt: Date };
type Named = { name: string };
An intersection requires an object to have the fields of both:
type Record = Timestamped & Named;const item: Record = { name: "Invoice", createdAt: new Date() };
One caution about this example: Record is also the name of a built-in utility type. Declaring your own alias with that name shadows the global one in that file, which is confusing at best, so prefer a more specific name in real code.
Functions
Parameter and return annotations are the core of a function's contract:
function multiply(a: number, b: number): number {
return a * b;
}
Optional parameters use ?, default values make a parameter optional and infer its type, and rest parameters collect any number of arguments into a typed array:
// optional parameter
function greet(name: string, title?: string): string {
return title ? `${title} ${name}` : name;
}// default parameter
function createOrder(item: string, quantity: number = 1) {
return { item, quantity };
}// rest parameters
function sum(...numbers: number[]): number {
return numbers.reduce((total, n) => total + n, 0);
}
Function types
You can also describe the shape of a function itself, which is handy for callbacks and strategy objects:
type MathOperation = (a: number, b: number) => number;
A function assigned to that type gets its parameter types from the annotation, so a and b need no annotations of their own:
const subtract: MathOperation = (a, b) => a - b;
Classes and access modifiers
TypeScript classes are JavaScript classes with typed properties and access modifiers. The class below starts by declaring a private balance and a read-only owner:
class Account {
private balance: number;
readonly owner: string;
The rest of the class sets those fields in the constructor and exposes methods to change and read the balance; accessing the private field from outside is rejected:
constructor(owner: string, initialBalance: number) {
this.owner = owner;
this.balance = initialBalance;
} deposit(amount: number): void {
this.balance += amount;
} getBalance(): number {
return this.balance;
}
}const acc = new Account("Priya", 1000);
acc.deposit(500);
console.log(acc.getBalance()); // 1500
acc.balance; // Error: Property 'balance' is private
The modifiers mean:
public, the default, is accessible everywhere.privateis accessible only inside the class.protectedis accessible inside the class and its subclasses.readonlyfields accept a value in the constructor and reject later reassignment.
Keep in mind that private is enforced only by the compiler; at runtime the property is an ordinary field. If you need true runtime privacy, JavaScript's # fields provide it, a trade-off covered in TypeScript private fields versus # syntax.
Interfaces as class contracts
An interface can define what a class must provide:
interface Shape {
area(): number;
}
With implements, the compiler verifies the class fulfills the contract, and a missing method is reported before the code runs. The constructor also uses a parameter property, private radius, which declares and assigns the field in one step:
class Circle implements Shape {
constructor(private radius: number) {} area(): number {
return Math.PI * this.radius ** 2;
}
}
Generics: reusable code that keeps its types
Generics are usually where TypeScript stops feeling like annotated JavaScript and starts feeling like a different tool.
The problem they solve
A helper that returns the first element of any array can be written with any:
function firstElement(arr: any[]) {
return arr[0];
}
It runs, but the type information is lost on the way out, and every result is any:
const num = firstElement([1, 2, 3]); // typed as `any` — no help from the compiler
const str = firstElement(["a", "b"]); // also `any`
A type parameter captures the element type of the input and reuses it for the return value:
function firstElement<T>(arr: T[]): T {
return arr[0];
}
Now each call gets a precise result type, inferred from the argument:
const num = firstElement([1, 2, 3]); // inferred as number
const str = firstElement(["a", "b"]); // inferred as string
T is a type variable that TypeScript fills in from what you pass. You keep the flexibility of any and gain the safety of a concrete type. Note that with an empty array, arr[0] is actually undefined at runtime; enabling noUncheckedIndexedAccess makes the compiler reflect that as T | undefined.
Generic interfaces
Generics work on interfaces too. A single response wrapper can describe every endpoint:
interface ApiResponse<T> {
success: boolean;
data: T;
}
The payload type is supplied where the wrapper is used:
const userResponse: ApiResponse<{ id: number; name: string }> = {
success: true,
data: { id: 1, name: "Sara" },
};
This is how many applications type their API layer: one ApiResponse<T> reused for users, products, orders and anything else the backend returns.
Constraining a type parameter
Sometimes T must guarantee certain properties. Describe the requirement as an interface:
interface HasLength {
length: number;
}
Then constrain the parameter with extends, so only types with a length are accepted:
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: number doesn't have .length
Utility types
TypeScript ships generic helper types that derive new types from existing ones, replacing a lot of hand-written boilerplate. Given a user model:
interface User {
id: number;
name: string;
email: string;
isAdmin: boolean;
}
You can derive variants instead of redeclaring them: Partial makes every property optional, Readonly makes them all read-only, Pick keeps selected keys, Omit removes them, and Record builds a dictionary type:
// Every property becomes optional — perfect for "update" functions
type UserUpdate = Partial<User>;// Every property becomes read-only
type ImmutableUser = Readonly<User>;// Pick only the fields you need
type UserPreview = Pick<User, "id" | "name">;// Everything except the fields you list
type PublicUser = Omit<User, "email" | "isAdmin">;// A dictionary shape: keys of one type, values of another
type UsersById = Record<number, User>;
A typical use is an update function that accepts any subset of fields:
function updateUser(id: number, changes: Partial<User>): void {
// merge `changes` into the stored user
}
Callers pass only what changes:
updateUser(1, { name: "New Name" }); // no need to pass email, isAdmin, etc.
The blog's guide to TypeScript's built-in utility types goes deeper into the full set.
Enums and literal types
Enums
An enum defines a named set of constants:
enum OrderStatus {
Pending,
Shipped,
Delivered,
}
Values are then referenced through the enum:
let status: OrderStatus = OrderStatus.Shipped;
By default members are numbered from 0. Assigning string values instead makes logs and network payloads far easier to debug:
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
String literal unions are often simpler
Many teams now prefer a union of string literals, because it produces no extra JavaScript and behaves more predictably:
type OrderStatus = "pending" | "shipped" | "delivered";
The compiler still rejects any value outside the set:
function updateStatus(status: OrderStatus) {
// ...
}updateStatus("shipped"); // OK
updateStatus("cancelled"); // Error: not assignable to type 'OrderStatus'
There is another practical reason to favor unions: enums generate runtime code, so tools that only strip types, such as Node.js's built-in TypeScript support, do not accept them.
Modules and tsconfig.json
Modules
TypeScript uses standard ES module syntax. One file exports a function:
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
Another imports it:
// app.ts
import { add } from "./math";
The settings that matter most
A generated config lists dozens of options, but a handful determine most of your experience:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist"
}
}
strict: trueenables the whole family of strict checks, includingstrictNullChecks, which forces explicit handling ofnullandundefined. Experienced developers treat it as non-negotiable, because without it TypeScript catches far fewer real bugs.noImplicitAnyreports an error wherever a value would otherwise fall back toanywithout you asking for it. It is already part ofstrict, so listing it separately only matters for readability.targetdecides which JavaScript version is emitted, and therefore how much modern syntax is rewritten for older runtimes.
When you inherit a legacy codebase with strict disabled, the sustainable path is to turn it on and work through the errors one file at a time, not to sprinkle any until the red lines disappear.
Common mistakes at each level
Beginner
- Annotating values that TypeScript would infer anyway.
- Reaching for
anyas soon as the compiler complains instead of narrowing the real type. - Dismissing compiler errors as noise when they are free code review.
Intermediate
- Declaring an
interfaceortypefor a one-off shape that inference would handle, adding ceremony without value. - Typing arrays as mutable and changing them with
pushorsplicewhen they should beReadonlyArray<T>and updated immutably. - Forgetting that a union must be narrowed before type-specific members are available, and fighting the compiler instead of reading its message.
Advanced
- Writing unconstrained generics that could be anything, which quietly defeats the purpose of making the function generic.
- Disabling
strictfor the whole project to hide a few errors instead of fixing or scoping them. - Treating a type assertion such as
value as Typeas if it validated anything. An assertion checks nothing at runtime; it only tells the compiler to trust you. Data from outside your program, including API responses, user input andlocalStorage, needs real validation.
Key takeaways
- TypeScript adds a compile-time checker to JavaScript; the types disappear when the code runs, so runtime boundaries still need validation.
- Annotate contracts such as function signatures and empty collections, and let inference handle the rest.
- Use
interfacefor extendable object shapes andtypefor unions, tuples and non-object types. - Learn discriminated unions early; they are the pattern that most directly prevents bugs in state-heavy code.
- Generics and utility types like
Partial,Pick,OmitandRecordare what turn scattered annotations into a real type system. - Keep
strict: trueon. The value of TypeScript is catching the mistakes you would otherwise find later and at greater cost, and that only works when the checker is allowed to do its job.