Skip to main content
TypeScript Type System

TypeScript Fundamentals

TypeScript adds a static type system on top of JavaScript. Understanding types is the foundation of everything else in TypeScript. If you skip this chapter, nothing else will make sense — types are to TypeScript what notes are to music theory.

How TypeScript Works: Transpilation

TypeScript is not directly executed. Browsers and Node.js only understand JavaScript. TypeScript must be transpiled (compiled) to JavaScript before it can run. Think of TypeScript as writing in a markup language (like Markdown) — the final consumer (the browser) never sees the markup, only the rendered output (JavaScript). The types are scaffolding that helps you build correctly, then gets removed from the finished building.

The TypeScript Compilation Pipeline

Type Erasure

Here is the key insight that many beginners miss: Types exist only at compile time. They are completely erased in the output JavaScript. This means TypeScript has zero runtime cost — no performance penalty, no extra bytes shipped to users. But it also means you cannot use TypeScript types for runtime decisions (more on that below).
Runtime Implications: Since types are erased, you cannot use TypeScript types for runtime checks. You can’t do if (typeof x === "string") based on a TypeScript string type—that’s just JavaScript’s typeof.

The TypeScript Compiler (tsc)

The tsc command is the TypeScript compiler:

Compile Time vs Runtime Errors

Why This Matters: TypeScript’s value is catching bugs BEFORE your code runs. But it can’t protect you from external data (APIs, user input)—you still need runtime validation for that.

1. Type Annotations

Type annotations explicitly declare the type of a variable, parameter, or return value.

Why Annotate?

TypeScript catches type mismatches at compile time, not runtime.

2. Type Inference

TypeScript is smart. It can infer types from the value you assign — meaning you often do not need to write types at all. The compiler looks at the right-hand side of the assignment and figures out the type automatically. This is what makes TypeScript feel lightweight instead of verbose like Java.
Best Practice: Let TypeScript infer types when the type is obvious from the value. Add explicit annotations when:
  • The type isn’t obvious
  • You’re declaring without initializing
  • You want to be explicit for documentation

When to Annotate vs Infer


3. Primitive Types

TypeScript has the same primitive types as JavaScript, plus a few extras.

Basic Primitives

Special Types

Avoid any! It defeats the purpose of TypeScript. Use unknown if you truly don’t know the type, then narrow it with type guards.

4. Arrays

Arrays can be typed in two ways:

Array Methods with Types


5. Tuples

Tuples are fixed-length arrays with specific types at each position. While a regular array says “this is a list of strings,” a tuple says “this is exactly a string, then a number, then a boolean, in that order.” They are useful for representing structured data without creating a full interface — like function return values that pack multiple pieces of information together.

Labeled Tuples (TS 4.0+)

Optional Tuple Elements


6. Enums

Enums define a set of named constants.

Numeric Enums

String Enums

const Enums (Inlined at compile time)

Modern Alternative: Many developers prefer union types over enums for better tree-shaking and simpler code:

7. Object Types

Define the shape of objects with inline types or type aliases.

Inline Object Types

Type Aliases

Create reusable type definitions.

8. Union Types

A value can be one of several types.

Narrowing Union Types

Literal Types


9. Type Assertions

Tell TypeScript you know better about the type. Assertions do not change the runtime value — they only override the compiler’s type analysis. Think of it as telling the compiler “trust me, I know what this is” rather than actually converting anything.

Non-null Assertion

Use assertions sparingly! They override TypeScript’s type checking. If you’re wrong, you’ll get runtime errors. Prefer type guards for safety.

10. Type Narrowing

Type narrowing is how you go from a broad type to a specific one inside a code block. TypeScript’s control flow analysis tracks which branches you have taken and automatically narrows the type — this is one of the most powerful and practical features of the type system.

typeof Guard

Truthiness Narrowing

instanceof Guard

in Operator


Summary

Next, we’ll explore functions and how TypeScript makes them safer and more expressive!

Interview Deep-Dive

Strong Answer:These three types represent the extremes of TypeScript’s type system, and confusing them is a common interview red flag.
  • any: The escape hatch. It disables all type checking for that value — you can call any method, access any property, assign it to anything. Think of it as telling the compiler “stop looking at this.” It is dangerous because errors that TypeScript would normally catch at compile time become runtime exceptions. The only legitimate uses are: migrating a large JavaScript codebase incrementally (temporarily marking untyped code as any), and interacting with truly dynamic third-party code where writing types is impractical. In production TypeScript, having any in your codebase is technical debt.
  • unknown: The type-safe alternative to any. It says “this value could be anything, but you must prove what it is before you use it.” You cannot access properties, call methods, or assign unknown to a typed variable without narrowing first (via typeof, instanceof, or a custom type guard). This is the correct type for external data: API responses, JSON.parse() output, user input, catch block errors. You accept the data as unknown, validate its shape, and only then work with it as a typed value.
  • never: Represents values that can never occur. A function that always throws returns never. A variable in the default branch of an exhaustive switch is never (if you handled all cases, no value can reach here). The most powerful use of never is exhaustiveness checking: const _exhaustive: never = shape; in a switch default forces a compile error if you add a new union variant without handling it.
The mental model: any is “I do not care about types,” unknown is “I do not know the type yet but I will find out,” and never is “this should be impossible.”Follow-up: Show me how ‘never’ is used for exhaustiveness checking in a discriminated union.Consider type Action = { type: 'add' } | { type: 'delete' } | { type: 'update' } and a switch on action.type. If you handle all three cases, the default branch’s action is narrowed to never — no value can reach it. Assigning const _: never = action compiles fine. Now if someone adds { type: 'archive' } to the union but forgets to add a case, the default branch sees action as { type: 'archive' }, which is not assignable to never, and the compiler errors immediately. This turns a potential runtime bug into a compile-time error, which is exactly the value proposition of TypeScript.
Strong Answer:Type erasure is the fundamental architectural decision of TypeScript: all type annotations, interfaces, type aliases, and generic type parameters are removed during compilation. The output JavaScript has zero traces of the type system.
  • What this means: interface User { name: string; email: string } does not exist at runtime. There is no User object, no User class, no User symbol in the compiled JavaScript. If you write if (x instanceof User), it is a compile error because User is not a value — it is a type that was erased.
  • Why this matters: You cannot validate incoming data against a TypeScript interface at runtime. JSON.parse(apiResponse) returns any (or unknown if you are careful), and no amount of TypeScript types can verify that the parsed object actually matches your User interface. The JSON could contain { name: 42, email: null } and TypeScript would not catch it because the type system is gone at runtime.
  • The solution: Runtime validation libraries (Zod, io-ts, Yup) or custom type guard functions. Zod, for example, lets you define a schema that serves as both a runtime validator and a TypeScript type: const UserSchema = z.object({ name: z.string(), email: z.string().email() }); type User = z.infer<typeof UserSchema>;. Now UserSchema.parse(data) validates at runtime, and User provides compile-time types — single source of truth.
  • Classes are the exception: TypeScript classes compile to JavaScript classes, so instanceof works on classes. This is why some teams prefer classes over interfaces for domain entities that need runtime validation.
Follow-up: How does type erasure affect generic types at runtime?Generic type parameters are completely erased. function identity<T>(value: T): T compiles to function identity(value) { return value; } — there is no way to know at runtime what T was. You cannot write if (T === string) inside the function because T does not exist at runtime. If you need different behavior based on the type, you must pass a runtime discriminator (an explicit string tag, a class constructor, or a type guard function) alongside the value.
Strong Answer:The rule I follow is: let TypeScript infer when the type is obvious from the assignment, annotate when it is not obvious or when you are defining a contract boundary.
  • Infer for local variables: const name = 'Alice' — the type string is obvious. Adding : string is visual noise that makes the code harder to scan. Same for const users = [{ id: 1, name: 'Alice' }] — TypeScript infers the full array type correctly.
  • Annotate function parameters always: function greet(name) gives name the type any (with strict mode, it is an error). Function parameters are contract boundaries — the caller needs to know what to pass. function greet(name: string) is a contract.
  • Annotate function return types for public APIs: For exported functions, library functions, or anything that forms a module boundary, explicit return types serve as documentation and prevent accidental type changes. If you refactor the function body and accidentally change the return type, an explicit annotation catches it. For internal helper functions, return type inference is usually fine.
  • Annotate when inference gets it wrong: const status = 'active' infers the literal type 'active', not string. If you want string, you annotate. Conversely, let status = 'active' infers string (because let can be reassigned). If you want the literal type, use as const or annotate.
  • Annotate empty initializations: const items = [] infers any[]. You must annotate: const items: string[] = [].
The key insight is that inference and annotation serve different purposes. Inference reduces verbosity for the code author. Annotations enforce contracts at boundaries for the code consumer. The best TypeScript codebases use inference liberally inside functions and annotations strictly at function signatures and module boundaries.Follow-up: What happens when TypeScript infers a type that is more specific than you want?This comes up with const declarations and object literals. const config = { timeout: 3000 } infers { timeout: number }, which is fine. But const method = 'GET' infers the literal type 'GET', not string. If you pass it to a function expecting string, it works (literals are subtypes of string). But if a function returns a string and you compare it to method, TypeScript may warn about comparing string to 'GET'. The tools are: as const to make types narrower (literal), explicit annotation to make types wider (general), and satisfies (TypeScript 4.9+) to validate a type without widening it.
Strong Answer:Control flow narrowing is one of TypeScript’s most sophisticated features, and it is what makes union types practical rather than painful.
  • The mechanism: TypeScript’s type checker tracks the type of every variable through every branch of your code. When you write if (typeof x === 'string'), TypeScript narrows the type of x to string inside the if block and to “whatever was left” in the else block. If x was string | number, it becomes string in the if and number in the else.
  • What triggers narrowing: typeof checks, instanceof checks, in operator ('swim' in animal), equality checks (x === null), truthiness checks (if (x)), and custom type guards (function isFish(x): x is Fish). TypeScript also narrows on assignment: if you assign a string to a string | number variable, the type narrows to string after the assignment.
  • Control flow analysis: TypeScript follows the actual control flow, not just the immediate block. If you check if (x === null) return;, TypeScript knows that after the return, x is not null for the rest of the function. This works with throw, return, break, and continue — any statement that makes a code path unreachable narrows the types for the remaining paths.
  • The limitation: Narrowing is per-reference, not per-value. If you narrow obj.value to string inside an if block, TypeScript narrows it for that block. But if you call a function in between that could mutate obj, TypeScript may widen the type back because it cannot guarantee the function did not change obj.value. This is why storing narrowed values in local constants (const val = obj.value; if (typeof val === 'string') ...) is a common pattern — local constants cannot be mutated by external calls.
Follow-up: How do custom type guard functions work, and why are they sometimes better than inline typeof checks?A custom type guard is a function whose return type is a type predicate: function isUser(data: unknown): data is User. When this function returns true, TypeScript narrows the argument to User in the calling scope. The advantage over inline checks is reusability and encapsulation. Validating that an API response is a User requires checking multiple properties (typeof data.name === 'string' && typeof data.id === 'number' && ...). Inlining that check everywhere is verbose and error-prone. Encapsulating it in isUser() means you write the validation once, test it once, and use it everywhere. TypeScript propagates the narrowing wherever you call it.