What Is TypeScript? A JavaScript Developer's Real Introduction to the Type System
JavaScript developers usually come to TypeScript one of two ways: because they had to, or because a production bug finally convinced them.
Either way, they hit the same first mistake: writing TypeScript like JavaScript, just adding occasional type annotations. This approach completely ignores TypeScript's most valuable features.
TypeScript isn't a linter — it's a design tool. The type system is a way of defining how your code behaves, and doing it well is a separate skill that needs to be learned.
Why TypeScript?
JavaScript is dynamically typed. A variable can start as a string and become a number. A function might not return the object you expected. This flexibility speeds up development but makes large codebases very hard to maintain.
// JavaScript: crashes at runtime function getUser(id) { return fetch(`/api/users/${id}`).then(r => r.json()); } const user = await getUser(123); console.log(user.naem); // undefined — typo, but no error sendEmail(user.email); // email could be undefined, nobody warned you
TypeScript catches these errors at compile time:
interface User { id: number; name: string; email: string; } async function getUser(id: number): Promise<User> { return fetch(`/api/users/${id}`).then(r => r.json()); } const user = await getUser(123); console.log(user.naem); // Error: Property "naem" does not exist on type "User" sendEmail(user.email); // email is always string, safe
Basic Types
// Primitives const name: string = "Alice"; const age: number = 25; const active: boolean = true; // Arrays const scores: number[] = [90, 85, 92]; const names: Array<string> = ["Alice", "Bob"]; // Tuple: fixed-length, fixed-type array const point: [number, number] = [10, 20]; // Any: disables the type system — avoid let data: any = "hello"; data = 42; // no error data.foo.bar; // no error — may crash at runtime // Unknown: safe alternative to any let input: unknown = getUserInput(); if (typeof input === "string") { input.toUpperCase(); // safe, type narrowed }
Using any is turning TypeScript off. Use unknown for genuinely unknown types — you're forced to check the type before using it.
Interface vs Type: When to Use Which?
// Interface: defines object shapes, extensible interface Animal { name: string; age: number; } interface Dog extends Animal { breed: string; } // Type: more flexible, powerful for unions and intersections type ID = string | number; type Status = "pending" | "active" | "cancelled"; type AdminUser = User & { adminLevel: number }; type Callback = (error: Error | null, result: string) => void;
Practical rule: prefer interface for object shapes, type for union types and complex type expressions.
Union and Intersection Types
// Union: A or B type Status = "success" | "error" | "loading"; function formatId(id: string | number): string { if (typeof id === "number") { return id.toString().padStart(6, "0"); } return id.toUpperCase(); } // Discriminated Union: powerful pattern type ApiResponse = | { status: "success"; data: User } | { status: "error"; message: string } | { status: "loading" }; function handleResponse(response: ApiResponse) { switch (response.status) { case "success": console.log(response.data.name); // TypeScript knows data exists break; case "error": console.log(response.message); // TypeScript knows message exists break; } }
Discriminated union is one of TypeScript's most powerful patterns. The compiler knows exactly what's available in each case.
Generics: Reusable Types
Generics let you write functions and classes that take type parameters. You don't have to rewrite the same logic for different types.
// Without generics: separate function for each type function getFirstString(arr: string[]): string { return arr[0]; } function getFirstNumber(arr: number[]): number { return arr[0]; } // With generics: one function for all types function getFirst<T>(arr: T[]): T { return arr[0]; } getFirst<string>(["a", "b", "c"]); // string getFirst<number>([1, 2, 3]); // number getFirst([true, false]); // boolean — inferred automatically // Generic interface interface ApiResponse<T> { data: T; status: number; message: string; } type UserResponse = ApiResponse<User>; type PostsResponse = ApiResponse<Post[]>; // Generic constraint function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { name: "Alice", age: 25 }; getProperty(user, "name"); // string getProperty(user, "age"); // number getProperty(user, "email"); // Error: "email" does not exist on type
Utility Types: Built-in Type Transformations
interface User { id: number; name: string; email: string; password: string; createdAt: Date; } type UserUpdate = Partial<User>; // all fields optional type RequiredUser = Required<User>; // all fields required type UserPreview = Pick<User, "id" | "name">; // { id, name } type PublicUser = Omit<User, "password">; // without password type ImmutableUser = Readonly<User>; // read-only fields type UserRoles = Record<string, "admin" | "editor" | "viewer">; // ReturnType: extract a function's return type function createUser(name: string, email: string) { return { id: Math.random(), name, email, createdAt: new Date() }; } type CreatedUser = ReturnType<typeof createUser>;
Utility types let you use the same interface in different contexts in different ways. Omit<User, "password"> strips the password from API responses. Partial<User> creates an optional update type for PATCH endpoints.
Actually Using TypeScript
The biggest trap when switching to TypeScript is gaming the type system. Silencing the compiler with any, bypassing type errors with as unknown as X, not leveraging type inference.
TypeScript's value comes from catching errors. To get that value, you have to take the type system seriously. Start with strict: true — it enables all strict checks. Challenging at first, pays off for every line of code it writes.