This guide introduces TypeScript as a language, not as a checklist of features. It assumes you know JavaScript well and focuses on how TypeScript changes the way you model data, define boundaries, and reason about programs.
The progression mirrors how TypeScript is meant to be learned: from concrete problems to increasingly expressive tools, each introduced to solve a real limitation in plain JavaScript.
JavaScript Assumptions and Why They Break Down
JavaScript allows values to flow freely.
This code is valid JavaScript. It also encodes multiple assumptions:
useris an object- it has an
emailproperty emailis a string
None of these assumptions are checked until runtime. If any are wrong, the failure happens when the function executes, often far from where the data was created.
As codebases grow, these assumptions become invisible. You no longer remember where user comes from, or what shape it was supposed to have. Bugs appear not because the logic is complex, but because expectations are unclear.
TypeScript exists to make those expectations explicit and checked.
Types As Descriptions, Not Constraints
In TypeScript, types are used to describe how values are intended to be used. They do not change how the code runs.
The type annotation on user documents what this function expects: an object with an email property that is a string. The benefit appears at call sites. If another part of the program tries to call sendEmail with incompatible data, TypeScript reports the mismatch during development.
This distinction is fundamental. TypeScript does not make JavaScript safer by enforcing rules at runtime. It makes programs safer by making intent visible before code runs.
This idea is foundational to the language and is emphasized throughout the official TypeScript Handbook, particularly in its discussion of how types describe everyday JavaScript values rather than changing runtime behavior:
Variables and Basic Types
TypeScript introduces explicit types for common JavaScript values.
Each annotation answers a simple question: what kind of value is this variable meant to hold? Once a variable has a type, TypeScript prevents it from being reused for incompatible data.
This prevents accidental reassignment and documents what kind of data a variable is meant to represent.
In practice, however, TypeScript often infers these types automatically.
Type Inference and Why It Matters Deeply
Type inference allows TypeScript to determine types based on how values are initialized and used.
Here, no annotations are written, but TypeScript still knows that total is a number and title is a string. From this point on, those variables behave exactly as if the types had been written explicitly.
Inference is critical for keeping TypeScript practical. Without it, even simple programs would be overwhelmed by repetitive type annotations.
A useful mental model is:
- Inference handles local facts
- Annotations communicate intent across boundaries
Functions As Explicit Contracts
Functions are one of the most important boundaries in a program. TypeScript treats them as explicit contracts between callers and implementations.
This signature describes both sides of the agreement:
- Callers must provide two numbers
- The function guarantees a number in return
If the implementation changes later, TypeScript ensures it still satisfies the declared contract. If callers change, TypeScript ensures they still call the function correctly.
This becomes increasingly valuable as functions are reused, shared, and maintained over time.
Objects and Structural Typing
Most real programs revolve around objects with known shapes.
This type describes the structure of a value, not its origin or identity. TypeScript uses structural typing, which means compatibility is based on shape rather than name. In other words, if an object has the required properties, it is considered a valid instance of that type.
Because user has the required properties, it is accepted, even though it was not explicitly declared as a User. This makes TypeScript well-suited to JavaScript codebases, where data is often created in-line or assembled gradually.
Structural typing is one of TypeScript’s most defining characteristics and is explored in depth in TypeScript Deep Dive, a widely referenced, community-maintained resource:
Optional Properties and Partial Data
Not all objects are fully populated at all times. Real programs frequently deal with partial data, incremental construction, or values that depend on external conditions.
The ? indicates that isAdmin may or may not be present. This does not mean the property exists with a value of undefined; it means the property itself might be missing entirely.
That distinction matters when the value is used.
Here, the comparison is explicit. The function only returns true when isAdmin exists and is set to true. All other cases, including missing data, return false.
TypeScript enforces this careful handling by refusing to treat optional properties as if they were always present. Uncertainty is made explicit rather than ignored.
Read-Only Properties and Protecting Invariants
Optional properties model uncertainty: a value may or may not be present. Read-only properties model a different concern: stability. Some data may be present, but it should not change once it exists.
The readonly modifier indicates that id can be read, but not reassigned, after the object is created.
Here, TypeScript prevents reassignment of id at compile-time.
Read-only properties communicate intent: This value is part of the object’s identity, not something that should change.
This becomes especially important when objects are passed through many layers of a program. Without readonly, any function that receives a User could potentially mutate its identity.
Interfaces As Evolving Contracts
Type aliases (like the User type above) are one way to describe object shapes. TypeScript also provides interfaces to serve as named contracts for object structures. For example, we could define the same shape with an interface:
Interfaces convey that this shape matters and will be reused. They are especially well-suited to codebases where data models evolve over time.
One reason for this is that interfaces can be extended. You can define a more specific interface by building on top of an existing one:
Anything that satisfies AdminUser must also satisfy User. As a result, values of the extended interface can be passed anywhere the base interface is expected.
Interfaces can also be merged across multiple declarations. When the same interface name is declared more than once, TypeScript combines the declarations into a single shape:
After merging, User is treated as having all three properties. This is particularly useful when augmenting types from libraries or gradually expanding shared models.
Just like type aliases, interfaces support optional properties (?) and readonly properties. In practice, interfaces and type aliases for objects are largely interchangeable, but interfaces shine when you expect a shape to grow, specialize, or be shared widely across a system.
Index Signatures and Unknown Keys
For situations where an object’s keys are not known in advance, TypeScript provides index signatures. An index signature allows you to describe types such as any string property name mapped to a number, for example:
This Scores type means an object can have any number of properties, as long as each property name is a string and each value is a number.
Even though the set of keys is open-ended, TypeScript still enforces consistency. Assigning a non-number value is rejected, regardless of which key is used.
An important consequence of index signatures is that every string-keyed property on the object must conform to the value type, which can make it awkward to mix well-known properties with dynamic keys in the same object.
Index signatures let you model dynamic dictionaries, lookup tables, and maps while still benefiting from static type checking. They are most useful at boundaries where data is keyed dynamically, but the value shape must remain uniform.
Arrays and Flowing Type Information
Arrays in TypeScript are typed based on the values they contain.
By declaring this variable as User[], TypeScript knows that every element of the array conforms to the User shape (even if some properties like isAdmin might be optional for each user).
That knowledge propagates automatically when the array is used.
Inside the callback, user is known to be a User, so user.email is recognized as a string. No additional annotations are required, and no type information is lost as we transform the array.
This ability for types to flow through higher-order functions is essential at scale. It allows complex transformations without drowning the code in manual annotations.
Tuples and Fixed-Position Data
TypeScript also supports tuple types, which are arrays of fixed length with specific types at each position. Tuples are useful when data has a known structure and ordering.
For example, you might represent a location as a pair of numbers (latitude and longitude), or a game score as a pair of values where the order matters:
Tuples enforce both the number of elements and the type of each element.
Because each position in a tuple has a specific meaning, TypeScript treats tuples differently from regular arrays. The type system tracks both the length and the position of each element.
Tuples are useful for representing structured data that always comes in a fixed format. Like arrays, tuple types carry their information through operations. When a tuple is transformed in a way that preserves its structure, TypeScript continues to understand its shape.
Union Types and Modeling Uncertainty
Many values cannot be described by a single type. Inputs often arrive in multiple valid forms.
This declaration states that id may be either a number or a string. Unions are especially common at system boundaries (such as user input, environment variables, JSON data, or API responses) where data can take different shapes.
A union type restricts what you can do with the value unless you know which member of the union you have. For example:
Here, id could be a number at runtime, and numbers don’t have a toUpperCase method. Because not all members of the union (number and string) support the same operations, TypeScript prevents unsafe usage. The type must be narrowed before it can be used in a way that assumes a specific type.
Narrowing and Control Flow Analysis
TypeScript analyzes how your code checks values and uses those checks to refine types.
The typeof check splits execution into two branches. Within each branch, TypeScript knows exactly which type id refers to. In the if block, id is treated as a string (so calling toUpperCase() is safe); in the else branch, id must be a number (so we can call toFixed(2)).
This process is called control flow analysis. It allows the type system to follow the same reasoning a human reader would, based on the code’s structure.
Control flow analysis is foundational. It enables safe handling of unions, optional values, and complex conditional logic throughout the language. Every if, typeof, instanceof, or other check you perform can guide TypeScript’s understanding of your code, reducing the chance of runtime errors.
The rules governing how TypeScript narrows types based on control flow are formally documented in the language handbook, which details how checks like typeof and conditionals influence type analysis.
Intersection Types and Composition
While union types model a choice between types, intersection types model a combination. An intersection type (denoted with &) requires a value to satisfy multiple type requirements simultaneously. It’s TypeScript’s way of saying: This value must be everything these types describe.
For example, imagine we have separate types for different aspects of an object and want to combine them:
The PersonRecord type here represents objects that have both a name and an age property. In other words, PersonRecord combines the requirements of HasName and HasAge. Any value of type PersonRecord must have all properties from both sides of the intersection.
Intersections let us build up more complex types from simpler pieces. They are useful for composing models or working with frameworks that add properties to objects. Because TypeScript’s type system is structural, you can intersect any object types (including interfaces or type aliases), and the result is a new type with all the members of each.
When two intersected types define the same property with incompatible types, TypeScript does not immediately report an error. Instead, it computes the intersection of the property types.
In this case, the id property becomes:
Because there is no value that is both a number and a string, the property type collapses to never:
The type is technically valid, but uninhabitable. There is no possible value that can satisfy it.
This is intentional. Rather than guessing which definition should win or silently widening the type, TypeScript preserves the conflict in the type itself. The moment you try to use the type, the inconsistency becomes unavoidable.
In practice, intersection types allow you to express both/and situations in your type definitions, complementing union types, which express either/or situations.
Literal Types and Constrained Values
Sometimes a value should be limited to a fixed set of allowed options.
This type encodes a closed set of valid states. Any value outside that set is rejected at compile-time.
Using literal types (in this case, string literals) is a way to create a sort of enum-like type without using the enum keyword. It’s very common to model state machines, configuration flags, or distinct modes using unions of literal types. By making invalid states unrepresentable, we eliminate entire categories of bugs.
TypeScript also offers an enum construct for defining named sets of constants that exist at runtime, but because enums emit JavaScript code and are less flexible than literal types, many codebases prefer literal unions unless a runtime representation is needed.
Discriminated Unions and Safe Variants
More complex data often comes in structured variants that share a common shape but differ in details. In TypeScript, we can model these as a discriminated union (also known as a tagged union or variant type).
Each variant in this union has a kind property with a literal type distinguishing its variant. This shared property acts as a discriminator (or tag) for the union.
Now, when we use a Result, we can check the kind to see which variant we have:
By checking the discriminator, TypeScript narrows the union to the correct variant. Each branch can only access the properties that are valid for that case.
This pattern scales to large and expressive domain models. You can have many variants, each with different shapes, and TypeScript will ensure that you only access the properties that exist for the current variant.
When handling discriminated unions, it’s common to use a switch on the discriminating field. In that form, TypeScript can enforce that all variants are handled explicitly.
This exhaustiveness pattern relies on the never type, which TypeScript uses to represent unreachable code paths and is formally documented in the language handbook.
TypeScript enforces exhaustiveness by ensuring the default branch is unreachable; adding a new variant makes it reachable, causing the value in that branch to be assigned to a never variable, which triggers a compile-time error.
Exhaustiveness checking like this guarantees that new cases cannot be introduced silently. When the domain model changes, the compiler forces every dependent decision point to be revisited, preventing entire classes of runtime errors by construction.
Patterns like discriminated unions tend to click fastest when you apply them repeatedly; Educative’s Learn TypeScript course includes guided exercises that reinforce these ideas in realistic codebases.
Classes As Typed Behavior
TypeScript supports class-based programming while adding static type checking.
Here, the constructor parameters declare and initialize class properties in a single step (public id: number creates an id property automatically). The class has a method sendEmail that can use this.email. TypeScript checks that instances are created with the right types for id and email, and it ensures that properties and methods are used correctly.
Classes combine structure (data like id and email) with behavior (methods like sendEmail). They do not introduce a separate, nominal type system: class instances are still checked by their shape. As a result, an instance of User is compatible with { id: number; email: string }, allowing class-based and object-literal styles to coexist naturally.
TypeScript classes also support inheritance and abstract classes. Using extends, subclasses inherit properties and methods, with the compiler enforcing correct constructor usage and compatible method overrides. Abstract classes, declared with the abstract keyword, define base classes that cannot be instantiated directly and require subclasses to implement any abstract members. Together, these features enable classic object-oriented patterns with compile-time safety.
Generics and Reusable Abstractions
Generics allow functions and types to work with many kinds of values without losing type information. They enable crafting flexible, reusable components that still preserve strict types.
The function wrap has a type parameter T representing the type of the value argument. Whatever type you pass in gets substituted for T in the return type.
Each call to wrap produces an object containing the value, and the result’s type is specific to what we passed in. We didn’t have to write separate functions for numbers and strings or manually declare the return type, the generic handled it.
Conceptually, generics let you parameterize types. They let one piece of code (function, interface, class, etc.) work with any type in a way that’s still checked.
Many built-in TypeScript features and APIs are generic. For instance, Array<T> is a generic type: an array of T holds elements of some type T (like Array<number> is a number array). By understanding generics, you unlock a lot of the power and flexibility of the type system.
Constraints and Meaningful Flexibility
Generics can be made more specific by adding constraints. A constraint on a type parameter lets you say: Any type T will work here, as long as T has a certain shape or capabilities.
In this function, the type parameter T is constrained by { length: number }. That means whatever type is substituted for T must have a length property that is a number. We don’t care what the type T exactly is, as long as this condition holds. Thanks to the constraint, inside getLength we know value.length is safe to use.
Constraints balance flexibility with safety. We get the wide applicability of generics while still making some assumptions explicit. They are a key building block for advanced typing patterns; many sophisticated types in TypeScript’s standard libraries (like utility types and reactive frameworks) rely on constrained generics to precisely control which forms of usage are allowed.
The anyType and Unchecked Escape Hatches
Some values enter a system without reliable type information. In those cases, it can be tempting to opt out of type checking entirely.
The any type tells TypeScript to stop checking this value. It can be read from, written to, and passed anywhere without restriction.
All of these operations are allowed, regardless of whether they make sense at runtime. TypeScript does not ask questions and does not provide guarantees.
Using any reduces the type visibility in a program. Errors are deferred to runtime, and incorrect assumptions are allowed to propagate unchecked.
Unknown Data and Type Safety Boundaries
Rather than using any for values coming from dynamic or external sources, a safer alternative is the unknown type.
The unknown type represents a value whose type is not yet known, but it forces you to do some checking before you can use it. Unlike any, you cannot directly use an unknown value without narrowing it to something more specific.
If you try to call input or access input.foo without any type checks, TypeScript will error. By doing a typeof check (or any other runtime check), we narrow input to a specific type (here, a string), and only then allow operations appropriate to that type. Outside the if block, input is still just unknown, so we can’t mistakenly use it as a string when it might not be one.
This aligns with TypeScript’s philosophy. Unsafe data must be validated before it is used, and the type system enforces that discipline.
Type Assertions and Responsibility
Sometimes you have more information about a value than TypeScript does. In those cases, you can use a type assertion to tell the compiler: Trust me, I know what I’m doing.
Here, you assert that the returned value is an HTMLElement, even though the DOM API allows for null.
Assertions do not perform checks and do not change runtime behavior. They bypass the type system and shift responsibility from the compiler to the programmer.
Used sparingly, they mark trust boundaries. Used excessively, they undermine the guarantees TypeScript provides.
How TypeScript Runs and Why That Matters
TypeScript is a compile-time tool. Your TypeScript code needs to be compiled (or transpiled) to plain JavaScript before it can run.
For example, using the TypeScript compiler from the command line:
This will strip away all the type annotations and TypeScript-specific syntax, producing a JavaScript file (such as index.js) that you can run with Node.js or in a browser. The emitted JavaScript will behave exactly as the TypeScript code did, just without types. In other words, once the program is running, it’s just JavaScript. There is no “TypeScript runtime” enforcing types; all the checking happens during development.
There is no TypeScript runtime and no additional execution cost. This design allows TypeScript to be adopted incrementally and to integrate seamlessly with existing JavaScript environments.