Skip to content

Latest commit

 

History

History
395 lines (288 loc) · 19.8 KB

File metadata and controls

395 lines (288 loc) · 19.8 KB

Guide to Fully Leveraging TypeScript (For New Juniors!)

Welcome to the world of TypeScript! Imagine you're building with LEGOs. JavaScript is like having a big pile of unsorted bricks, and you just start building, hoping everything fits. TypeScript is like having a very organized LEGO set, where each brick is labeled with its type (e.g., "2x4 red brick," "4x2 blue plate"). This organization helps you:

  • Build faster: You know which pieces fit where without trial and error.
  • Build stronger: Your structures are less likely to fall apart unexpectedly.
  • Build bigger: It's easier to manage many pieces and collaborate with others.

"Leveraging TypeScript" means using all the labels, instructions, and organizational tools that TypeScript gives you to build truly amazing, strong, and easy-to-understand software. It helps prevent tiny mistakes from becoming huge problems later on.


1. Starting Strong: Turn on All the Safety Checks with "strict": true

When you first set up a TypeScript project, you'll see a file called tsconfig.json. This is like the instruction manual for your TypeScript assistant. One of the most important settings in it is "strict": true.

// tsconfig.json (inside compilerOptions)
{
  "strict": true // This is your main safety switch!
}

What it does: By setting "strict": true, you're telling TypeScript to be extra vigilant and turn on many powerful checks. The most impactful one for beginners is strictNullChecks.

Why it matters: Think about a common error in JavaScript: "Cannot read properties of undefined." This happens because you tried to use something that wasn't there (like user.name when user was null or undefined). With strictNullChecks enabled, TypeScript will warn you before you run your code if a variable might be null or undefined where you're trying to use it. This forces you to handle those "empty" cases directly, preventing many crashes and bugs. It's like having a warning label on every potentially empty box.

For a professional project: Starting with strict: true is foundational. It immediately raises the quality bar, pushing developers to write more robust and predictable code from day one. It's much harder to introduce these strictness checks into a large project later.


2. Smart Guessing vs. Clear Instructions: Inference and Explicit Types

TypeScript is smart. It can often guess the type of your data based on how you first use it. This is called type inference.

  • Let TypeScript infer when it's obvious:

    const productName = "Laptop Pro"; // TypeScript infers this is a `string`
    let stockCount = 150; // TypeScript infers this is a `number`
    const isActive = true; // TypeScript infers this is a `boolean`

    You don't need to write : string or : number here; TypeScript just knows. This keeps your code clean.

  • Give clear instructions (explicit types) when it helps: Sometimes, for the sake of clarity, or when you're defining a "contract" for how different parts of your code should communicate, it's better to be explicit. This is especially true for function inputs (parameters) and outputs (return values), or for reusable data blueprints.

    // This is a blueprint for what a 'Product' object must look like
    interface Product {
      id: string;
      name: string;
      price: number;
    }
    
    // This function clearly states it needs a 'Product' object
    // and will always return a `string`.
    function formatProductDisplay(product: Product): string {
      return `${product.name} - $${product.price.toFixed(2)}`;
    }

Why it matters: Inference saves you typing. Explicit types act as self-documentation and ensure that if someone uses your function or data blueprint, they must provide data that matches your clear instructions. This prevents miscommunication errors between different parts of your program (and different developers!).

For a professional project: This balance is key. It ensures clarity at critical "boundary" points (like API calls or function interfaces) while letting TypeScript handle the obvious in between, leading to readable and maintainable code.


3. Building Blueprints: Interfaces vs. Types

When you want to define what a specific "shape" of data looks like (e.g., an object with certain properties), you'll primarily use interface or type. They're very similar but have some subtle differences.

  • interface (Best for Objects and "Contracts"): Use interface when you're describing the structure of an object or when you want to define a "contract" that classes can promise to follow.

    // An interface describing a Car object
    interface Car {
      make: string;
      model: string;
      year: number;
      startEngine(): void; // An interface can also describe methods!
    }
    
    // A class can 'implement' an interface, promising to follow its contract
    class ElectricCar implements Car {
      make: string;
      model: string;
      year: number;
    
      constructor(make: string, model: string, year: number) {
        this.make = make;
        this.model = model;
        this.year = year;
      }
    
      startEngine() {
        console.log("Whirr... electric engine started.");
      }
    }

    Interfaces can also be "extended" by other interfaces to add more properties, and they can be "merged" if you define the same interface name twice, which is useful in advanced scenarios like extending library types.

  • type (Type Alias - Best for Everything Else): Use type when you want to give a simpler name to any type that isn't just a basic object. This is perfect for:

    • Simple combinations:
      type ID = string | number; // An ID can be either a string OR a number
    • Exact values:
      type Color = "red" | "green" | "blue"; // A Color can ONLY be these specific strings
    • Function blueprints:
      type GreetFunction = (name: string) => string; // A function that takes a string and returns a string
    • Fixed lists (Tuples):
      type Coordinates = [number, number]; // Exactly two numbers, in this order

Why it matters: Choosing between interface and type helps clearly communicate your intent. interface often implies a more extensible, object-oriented "contract," while type is more about creating convenient aliases or precise combinations of types.

For a professional project: Consistent usage improves readability. Using interface for object definitions allows for better future extensibility (e.g., when adding new features that modify existing objects) and cleaner class implementations.


4. Being Super Specific: Union, Intersection, & Literal Types

These are like special tools to combine or specify types very precisely.

  • Union Types (A | B): "Either this OR that" A variable with a union type can hold a value of any one of the specified types.

    type Status = "pending" | "success" | "error"; // A variable of type Status can only be 'pending', 'success', or 'error'
    let orderStatus: Status = "pending";
    orderStatus = "success";
    // orderStatus = 'failed'; // TypeScript would give an error here!
  • Intersection Types (A & B): "This AND that" A variable with an intersection type must have all the properties from all the combined types. It's like merging objects.

    interface Logger {
      log(message: string): void;
    }
    interface ErrorHandler {
      handleError(error: Error): void;
    }
    
    type AppUtility = Logger & ErrorHandler; // An AppUtility must have both log() AND handleError() methods
    
    const myAppTool: AppUtility = {
      log: (msg) => console.log(msg),
      handleError: (err) => console.error(err.message),
    };
  • Literal Types: "Exactly this value" This allows you to use specific string, number, or boolean values as types, making your code very strict about what's allowed.

    type ButtonSize = "small" | "medium" | "large";
    function createButton(size: ButtonSize) {
      /* ... */
    }
    
    createButton("medium");
    // createButton('extra-large'); // Error: Not allowed!

Why it matters: These types help you create a very precise "schema" for your data and parameters. This means TypeScript can catch more errors, making your code safer and less prone to unexpected values.

For a professional project: Using these prevents passing invalid values into functions or assigning incorrect states. It makes your code's intentions crystal clear, reducing misunderstandings when multiple developers work together.


5. Writing Reusable Code: Generics (The "Placeholder" Type)

Generics are a superpower in TypeScript! They allow you to write functions, classes, or interfaces that can work with a variety of different types, without losing any of TypeScript's safety checks. Think of them as placeholder types that you fill in later.

// Imagine you want a function that returns the first item of ANY list.
// Without generics, you'd have to write one for strings, one for numbers, etc.:
// function getFirstString(arr: string[]): string | undefined { return arr[0]; }
// function getFirstNumber(arr: number[]): number | undefined { return arr[0]; }

// With Generics: One function for all types!
function getFirst<T>(list: T[]): T | undefined {
  // 'T' is our placeholder type variable
  return list[0];
}

const firstFruit = getFirst(["apple", "banana", "cherry"]); // Here, 'T' becomes `string`
console.log(firstFruit.toUpperCase()); // Works because TypeScript knows it's a string!

const firstID = getFirst([101, 202, 303]); // Here, 'T' becomes `number`
console.log(firstID.toFixed(0)); // Works because TypeScript knows it's a number!

You can apply generics to interfaces and classes too!

Why it matters:

  • Reusability: Write code once, use it everywhere with different types.
  • Type Safety: Even with placeholders, TypeScript ensures you use the type correctly. The getFirst function above won't let you try firstID.toUpperCase() because it knows firstID is a number.
  • Reduced Duplication: Less repetitive code, which is a key goal in clean architecture.

For a professional project: Generics are essential for building flexible and robust libraries, utility functions, and API wrappers (like your Monads, as we'll see later). They make your codebase scalable and easy to extend without having to rewrite common logic for every new data type.


6. Smart Type Detective: Type Guards & Narrowing

Sometimes, a variable can have multiple possible types (a "union type" like string | number). Type guards are special checks that help TypeScript figure out which exact type a variable is at a specific moment in your code. This process is called type narrowing.

function displayValue(value: string | number) {
  // At this point, 'value' could be a string OR a number

  if (typeof value === "string") {
    // This is a `typeof` type guard
    // INSIDE this 'if' block, TypeScript knows 'value' is DEFINITELY a `string`
    console.log(`Text: ${value.toUpperCase()}`); // We can safely use string methods
  } else {
    // In this 'else' block, TypeScript knows 'value' is DEFINITELY a `number`
    console.log(`Number: ${value.toFixed(2)}`); // We can safely use number methods
  }
}

displayValue("hello world"); // Output: Text: HELLO WORLD
displayValue(123.456); // Output: Number: 123.46

You can also create your own custom type guard functions:

interface Dog {
  bark(): void;
}
interface Cat {
  meow(): void;
}

// This is a custom type guard function
function isDog(pet: Dog | Cat): pet is Dog {
  return (pet as Dog).bark !== undefined; // Check if the 'bark' method exists
}

function makeSound(pet: Dog | Cat) {
  if (isDog(pet)) {
    // TypeScript now knows 'pet' is a `Dog` here
    pet.bark();
  } else {
    // 'pet' is a `Cat` here
    pet.meow();
  }
}

Why it matters:

  • Prevents Runtime Errors: You can use methods specific to a type without fear of crashing your app (e.g., calling .toUpperCase() on a number).
  • Cleaner Code: Reduces the need for defensive programming hacks or casting ((value as string).toUpperCase()).
  • More Accurate Type Checking: TypeScript's understanding of your code becomes much more precise within conditional blocks.

For a professional project: Type guards are indispensable for handling flexible data, especially when dealing with API responses that might return different shapes or values (Either and Maybe Monads heavily rely on this concept internally). They make your code more robust and easier to debug.


7. Smart Type Shortcuts: Utility Types

TypeScript comes with a set of built-in "utility types" that act like shortcuts or helpers to transform existing types into new ones. They save you a lot of manual work and keep your types consistent.

  • Partial<T>: Makes all properties of a type T optional.

    interface UserProfile {
      name: string;
      email: string;
      age: number;
    }
    
    type EditableProfile = Partial<UserProfile>;
    // Result: { name?: string; email?: string; age?: number; }
    // Now you can update only parts of the profile.
  • Required<T>: Makes all properties of a type T required.

  • Readonly<T>: Makes all properties of a type T read-only. You can't change them after creation.

  • Pick<T, K>: Creates a new type by picking only specific properties K from a type T.

    type UserSummary = Pick<UserProfile, "name" | "email">;
    // Result: { name: string; email: string; }
  • Omit<T, K>: Creates a new type by removing specific properties K from a type T.

    type UserForDisplay = Omit<UserProfile, "age">;
    // Result: { name: string; email: string; }
  • ReturnType<T>: Gets the return type of a function.

    function fetchUserData() {
      return { id: "abc", name: "Alice", isLoggedIn: true };
    }
    
    type UserDataType = ReturnType<typeof fetchUserData>;
    // Result: { id: string; name: string; isLoggedIn: boolean; }

Why it matters: Utility types help you define new types quickly and consistently based on existing ones. This reduces duplication in your type definitions and makes your type system more maintainable.

For a professional project: They are indispensable for managing different "views" or "versions" of your data shapes (e.g., a full User object for the database, a Partial<User> for an update form, a Pick<User, ...> for a display component).


8. Knowing When You Don't Know: unknown vs. any (The Safer Choice)

These two types are like "wildcards," but one is far safer than the other.

  • any (Avoid as much as possible!):

    • This is the ultimate "type escape hatch." When you use any, you're telling TypeScript: "Just ignore this variable completely. Don't check its type at all."
    • Danger: It completely disables type checking for that variable. If you make a mistake, TypeScript won't warn you, and you'll only find out about the error when your code crashes at runtime. It's like turning off all your smart assistant's checks.
    let data: any = JSON.parse('{"name": "Alice"}');
    data.nonExistentMethod(); // No error from TypeScript, but will crash at runtime!
  • unknown (Prefer!):

    • This means: "I have a value here, but I don't know its exact type yet. You (TypeScript) must force me to check its type before I can do anything with it."
    • Safety: You must use a type guard (like typeof or instanceof) to narrow the unknown type down to something more specific before you can operate on it. This forces you to handle all possibilities, making your code much safer. It's like your smart assistant saying, "Okay, I don't know what's in this box, but you need to label it or check inside before you can play with it!"
    function processParsedData(value: unknown) {
      // value.someMethod(); // Error: 'value' is of type 'unknown'. You must narrow it first.
    
      if (typeof value === "object" && value !== null && "name" in value) {
        // Now TypeScript knows 'value' has a 'name' property
        console.log((value as { name: string }).name); // Safe type assertion after check
      } else {
        console.log("Value is not an object with a name.");
      }
    }

Why it matters: Always prefer unknown over any when you truly don't know a type. unknown forces you to write safe, defensive code, while any allows you to introduce unchecked bugs.

For a professional project: Using unknown is a sign of disciplined type safety. It ensures that data coming from unpredictable sources (like external APIs or user input) is handled safely and explicitly, rather than being a potential source of hidden bugs.


9. The Synergy: How TypeScript Supercharges Your Architecture

The real magic happens when you combine these TypeScript features with the architectural patterns we've discussed (like separating layers, using Domain Objects, and Monads). This creates an exceptionally robust, separated, and understandable codebase.

  • Goodbye null Errors (with Domain Abstraction):

    • Your IDomain interface, combined with strictNullChecks and the empty() method on your Domain Objects (e.g., BadgeDomain.empty()), means you are always dealing with a concrete object. You check if (myObject.isEmpty) instead of if (myObject !== null && myObject !== undefined), making your UI and logic much cleaner and safer. TypeScript guarantees the structure even if the data is "empty."
  • Predictable API Responses (with Monads):

    • When your wrapServiceCall returns an Either Monad (e.g., IEither<ResponseOnError, ServiceWrapperSuccess<T>>), TypeScript's generics specify exactly what kind of error (ResponseOnError) and what kind of successful data (ServiceWrapperSuccess<T>) to expect.
    • You use Either.fold() to safely handle both success and error paths. TypeScript forces you to handle both, so you can't forget error cases.
    • Within the success path, you might then use a Maybe Monad to handle cases where data might be "not found" (e.g., Maybe.some(data) or Maybe.none()). This uses type guards internally and forces you to handle the presence or absence of data explicitly.
    • The map and flatMap methods on these Monads leverage pure functions and immutability. They let you chain operations on your data safely, knowing that if an error or empty state occurs, the chain will "short-circuit," and the error/empty state will cleanly propagate, preventing unexpected side effects.
  • Agnostic and Decoupled Code:

    • API Agnosticism: Your API Service layer, using wrapServiceCall with Either, transforms messy backend responses into a consistent IEither format. Your stores and components don't need to know the specific details of your database (like Supabase errors).
    • Domain Agnosticism: Your Domain Objects (e.g., BadgeDomain) take the clean data from the service layer and transform it into your app's internal business-friendly model. Your UI components only see BadgeDomain, not the original API structure.
    • TypeScript helps enforce these boundaries, ensuring layers only communicate through their defined interfaces, making your code easier to change and maintain without breaking other parts.
  • Catch Bugs Early and Document Clearly:

    • By strictly defining types and using TypeScript's features, you catch many bugs before your code even runs.
    • Types act as a living documentation. When a new developer joins, they can quickly understand what data shapes are expected and what functions do just by looking at the types.

By embracing TypeScript and actively using its powerful features, you're not just adding types; you're fundamentally transforming how you write software, leading to a much more reliable, understandable, and future-proof application.