Skip to main content

Functions & Types

Functions are the building blocks of any application. TypeScript makes functions safer by typing parameters, return values, and function signatures themselves. Think of typed function parameters as a form with labeled fields — instead of accepting “anything” and hoping the caller passes the right data, you specify exactly what each slot expects. The compiler then rejects any call that does not match the form.

1. Function Type Annotations

Parameter and Return Types

void and never

Practical tip: The never type is especially useful in exhaustive switch statements. If you handle all cases, the default branch’s variable becomes never — and if you add a new case later without handling it, the compiler errors. This is a powerful safety net for growing codebases.

2. Optional and Default Parameters

Optional Parameters

Default Parameters

Optional vs Default: Optional parameters can be undefined. Default parameters have a fallback value. Default parameters are usually preferred.

Rest Parameters


3. Function Type Expressions

Define the type of a function itself — not what it does, but what its shape looks like. This is like defining a “job description” that any function matching the signature can fill.

Call Signatures

Construct Signatures


4. Function Overloads

Define multiple function signatures for different parameter combinations. Overloads let you express something that a single signature cannot: “if you pass a string, you get back X; if you pass a number, you get back Y.” The compiler uses the overload signatures (the declarations) to check your call sites, while the implementation signature (the body) handles the actual logic.

More Complex Overloads

Overloads Rule: The implementation signature must be compatible with all overload signatures but is not visible to callers.

5. Generic Functions

Write functions that work with any type while maintaining type safety. Generics are like templates or molds — you define the shape once, and the caller fills in the specific type. Without generics, you would have to choose between type safety (writing separate functions for each type) and flexibility (using any and losing all type information).

Basic Generics

Multiple Type Parameters

Generic Constraints

keyof Constraint


6. Type Guards

Functions that narrow types at runtime. Type guards bridge the gap between TypeScript’s compile-time type system and JavaScript’s runtime behavior. They let you write conditional logic that both the runtime and the compiler understand.

typeof Guards

instanceof Guards

Custom Type Guards (Type Predicates)

Custom type guards are one of the most powerful patterns in TypeScript. The is keyword in the return type tells the compiler: “if this function returns true, treat the parameter as the specified type.” This lets you encapsulate complex runtime checks in a reusable function.
Practical tip: Custom type guards are essential for validating API responses. You can write an isUser(data: unknown): data is User function that validates the shape of external data, giving you both runtime safety and compile-time narrowing in one step.

Discriminated Unions


7. Assertion Functions

Functions that throw if a condition is false, narrowing the type for all code that follows. Unlike type predicates (which narrow inside an if block), assertion functions narrow the type for the rest of the function scope — everything below the assertion call benefits from the narrowed type.

Non-null Assertion


8. this Parameter

Explicitly type the this context.

This Parameter in Classes


9. Callback Types

Type callbacks precisely.

10. Practical Examples

API Function Types

Event Emitter Pattern


Summary

Next, we’ll explore objects and interfaces in depth!

Interview Deep-Dive

Strong Answer:This is a design decision that many TypeScript developers get wrong because they default to overloads when simpler alternatives exist.
  • Use overloads when: The return type depends on the input type, and you want the caller to get the precise return type based on what they passed. Classic example: createElement('div') returns HTMLDivElement, createElement('input') returns HTMLInputElement. A single signature createElement(tag: string): HTMLElement loses this precision — the caller always gets the generic HTMLElement and must cast. Overloads preserve the mapping from specific input to specific output.
  • Use union return types when: The function legitimately returns different types regardless of input, or the relationship between input and output is not 1-to-1. For example, function parse(input: string): number | Error — every input is a string, and the output depends on the content, not the type. Overloads would not help here because there is no type-level distinction between “a string that parses” and “a string that does not.”
  • The trade-off: Overloads add complexity. The implementation signature must be compatible with all overload signatures, which often means the implementation body is full of type assertions and conditional logic. If you have 5 overloads, you have 5 signatures to maintain. Generics with conditional types often achieve the same result with less code: function identity<T extends string | number>(value: T): T extends string ? string : number gives you input-dependent return types without overloads.
  • My rule of thumb: If you have 2-3 distinct input-output mappings, overloads are clear and readable. If you have more, generics with conditional types are usually cleaner. If the return type does not depend on the input type, just use a union.
Follow-up: What is the gotcha with the implementation signature in overloads?The implementation signature is not visible to callers — only the overload signatures are. This means if the implementation accepts string | number | Date, but you only have overloads for string and number, callers cannot pass a Date even though the implementation handles it. The overload signatures act as the public API; the implementation signature is an internal detail. A common mistake is writing an implementation signature that is too narrow, causing one of the overloads to be incompatible, which TypeScript flags as an error.
Strong Answer:Both are tools for narrowing types, but they work in fundamentally different scopes and have different failure modes.
  • Type predicates (is): Used in if conditions. function isString(x: unknown): x is string narrows the type inside the if block where the return value is true. The function returns a boolean — it does not throw. The caller decides what to do when the check fails (return early, show an error, use a default). Narrowing is limited to the if block.
  • Assertion functions (asserts): Narrow the type for the entire remaining scope. function assertIsString(x: unknown): asserts x is string throws if the condition fails. After the call, TypeScript treats x as string for every line that follows — not just inside an if block. The function either succeeds (and narrows) or throws (and execution stops). There is no “else” path.
  • When to use type predicates: Filtering, branching, optional handling. const users = items.filter(isUser) uses a type predicate to narrow the array type. if (isAdmin(user)) { showAdminPanel(); } — the non-admin path is a valid execution path, not an error.
  • When to use assertion functions: Validation at boundaries where invalid data is a bug, not an expected condition. assertDefined(config.apiKey) at application startup — if the API key is missing, crashing immediately with a clear error message is the correct behavior. Assertion functions are also useful in test setup: assertIsHTMLElement(element) before interacting with it.
  • The danger of assertion functions: If your assertion is wrong (the function returns without throwing when it should have thrown), TypeScript trusts you and narrows the type incorrectly. This is a silent type safety violation. Type predicates are safer because they return a boolean that can be unit-tested; assertion functions are harder to test for the “should have thrown but did not” case.
Follow-up: Can you use assertion functions with array methods like filter?No. Array.prototype.filter expects a function returning boolean, and assertion functions return void (they assert or throw). You must use type predicates with filter: items.filter((item): item is User => isUser(item)). Assertion functions are for imperative code paths where you want to “gate” execution, not for declarative operations like filtering or mapping.
Strong Answer:Generic constraints are how you tell TypeScript “this type parameter is not completely open — it must satisfy certain requirements.” Without constraints, generics are too permissive; with them, you get precision.
  • The problem without constraints: function getLength<T>(value: T): number { return value.length; } fails because TypeScript does not know that T has a length property. T could be number, which has no length. The compiler correctly rejects this.
  • The solution with extends: function getLength<T extends { length: number }>(value: T): number tells TypeScript: “T can be any type, as long as it has a length property of type number.” Now strings, arrays, and any object with length work, but numbers are rejected at the call site.
  • Where keyof elevates this: function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] constrains K to be one of the actual keys of T. If T is { name: string, age: number }, then K can only be 'name' or 'age'. The return type T[K] is automatically the type of that specific property — if you pass 'name', you get string; if you pass 'age', you get number. Without keyof, you would have to accept key: string and return any, losing all type safety.
  • Real-world example: A type-safe pick function: function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K>. This guarantees at compile time that you can only pick keys that exist on the object, and the return type precisely reflects which keys you picked. This is impossible without the keyof constraint.
Follow-up: What is the difference between T extends keyof U and K extends keyof T in terms of how the types are resolved?K extends keyof T constrains K to be a union of string literal types representing the keys of T. When you call getProperty(user, 'name'), TypeScript infers T = typeof user and K = 'name'. The return type T[K] resolves to T['name'] which is string. If T is generic (not yet resolved), T[K] remains a deferred type that resolves when T is known. The key insight is that keyof produces a union of literal types at the type level, not a runtime value — it is purely a compile-time constraint that prevents invalid property access.
Strong Answer:The this parameter is TypeScript’s solution to one of JavaScript’s most notorious footguns: the value of this depends on how a function is called, not how it is defined.
  • The problem: In JavaScript, if you extract a method from an object and call it standalone, this is undefined (strict mode) or the global object (sloppy mode). const greet = user.greet; greet();this.name is undefined inside greet because the method lost its binding. This is the root cause of countless bugs in event handlers: button.addEventListener('click', user.handleClick) — inside handleClick, this is the button element, not the user object.
  • TypeScript’s solution: The this parameter is a fake first parameter that is erased at compile time (like type annotations). greet(this: User): string tells TypeScript: “this function must be called with this bound to a User.” If you try to call it without the right context, TypeScript errors. This catches the detached method bug at compile time.
  • In classes, arrow functions are the pragmatic fix: increment = () => { this.count++; } captures this lexically from the class instance. It is safe to pass as a callback because this always refers to the instance. The trade-off is that arrow methods are per-instance (each instance gets its own copy), not on the prototype. For a class with 10,000 instances, that is 10,000 copies of the function instead of one shared prototype method. For UI components (where instances are few), this is irrelevant. For data classes in tight loops, it can matter.
  • strictBindCallApply config: When enabled, TypeScript checks that Function.prototype.bind, .call, and .apply are called with the correct types. greet.call({ name: 42 }) would error because { name: 42 } does not match the this: User parameter. Without this flag, these calls are unchecked.
Follow-up: When would you choose an explicit this parameter over an arrow function in a class?When you want the method on the prototype (shared across instances) and you control all call sites. For a library API where you document “call this method on the object, do not extract it,” the this parameter provides compile-time enforcement without the per-instance memory cost. Arrow functions are better when the method will be passed as a callback (event handlers, React callbacks, Promise chains) because you cannot control how the caller invokes it. The judgment call is: “will this method ever be detached from its object?” If yes, arrow function. If no, prototype method with this parameter.