DEV Community

Cover image for Functions, Generics, and the Stuff That Looks Familiar But Isn't
Gabriel Anhaia
Gabriel Anhaia

Posted on

Functions, Generics, and the Stuff That Looks Familiar But Isn't


Java generics feel like paperwork. TypeScript generics feel like a tool. Same concept, very different experience.

I spent years writing Java and PHP before picking up TypeScript. The generics syntax looked familiar enough. <T>, constraints, return types. But once I started writing real code, I realized the similarities were surface-level. Functions in TypeScript behave differently than methods in Java. Generics show up in places I didn't expect. And there's a whole category of type-level features -- type guards, satisfies, structural constraints -- that don't map to anything in my previous stack.

This is Post 3 in the series. Post 1 covered the mental model shift. Post 2 covered the type system, unions, and discriminated unions. If you haven't read those, the generics section here will still make sense, but the type narrowing part builds on concepts from Post 2.

Functions Are Values, Not Just Methods

Every function in Java lives inside a class. No exceptions. Even a static utility method is attached to a class. PHP is similar -- standalone functions exist, but in modern PHP you're mostly writing methods on classes.

TypeScript doesn't work that way. Functions are values. You can assign them to variables, pass them as arguments, return them from other functions, store them in arrays. This isn't a TypeScript thing specifically -- it's a JavaScript thing that TypeScript inherited. But if you're coming from Java, it's a real mindset shift.

// this is a function declaration
function add(a: number, b: number): number {
  return a + b;
}

// this is a function assigned to a variable
const subtract = function (a: number, b: number): number {
  return a - b;
};

// this is an arrow function assigned to a variable
const multiply = (a: number, b: number): number => a * b;

// all three are callable the same way
add(2, 3);       // 5
subtract(5, 3);  // 2
multiply(4, 3);  // 12
Enter fullscreen mode Exit fullscreen mode

All three are just values of type (a: number, b: number) => number. You can pass any of them anywhere that type is expected. There's no interface to implement, no class to extend.

Typing Functions

The basics look like what you'd expect from a statically typed language. Parameters get types, return values get types.

function greet(name: string): string {
  return `Hello, ${name}`;
}
Enter fullscreen mode Exit fullscreen mode

Compare that to a Java method signature:

public String greet(String name) {
    return "Hello, " + name;
}
Enter fullscreen mode Exit fullscreen mode

Similar enough. But TypeScript has a few things Java doesn't.

Optional parameters use ?. They become T | undefined automatically:

function createUser(name: string, email?: string): User {
  return {
    name,
    email: email ?? "not provided",
  };
}

createUser("Gabriel");              // works
createUser("Gabriel", "g@test.com"); // also works
Enter fullscreen mode Exit fullscreen mode

Default values work like you'd expect, and they make the parameter optional automatically:

function paginate(items: string[], page: number = 1, size: number = 20) {
  const start = (page - 1) * size;
  return items.slice(start, start + size);
}

paginate(myItems);        // page 1, size 20
paginate(myItems, 3);     // page 3, size 20
paginate(myItems, 2, 50); // page 2, size 50
Enter fullscreen mode Exit fullscreen mode

Rest parameters collect remaining arguments into an array:

function log(level: string, ...messages: string[]): void {
  console.log(`[${level}]`, ...messages);
}

log("INFO", "Server started", "Port 3000", "Ready");
Enter fullscreen mode Exit fullscreen mode

Java has varargs (String... messages), so this is familiar. The typing is just more explicit -- you see it's string[] right there in the signature.

Functions as Values

This is where things diverge from Java-land. In TypeScript, you'll constantly pass functions as arguments to other functions. Callbacks are everywhere.

Here's how you type a callback parameter:

function fetchData(url: string, onSuccess: (data: string) => void): void {
  // imagine some HTTP call
  const result = "some data";
  onSuccess(result);
}

fetchData("/api/users", (data) => {
  console.log("Got:", data); // TypeScript infers data is string
});
Enter fullscreen mode Exit fullscreen mode

Notice that (data: string) => void in the parameter list. That's a function type. It says "this parameter is a function that takes a string and returns nothing." In Java, you'd need a Consumer<String> or a custom functional interface for this.

Higher-order functions -- functions that take functions and return functions -- are common in TS codebases. Here's a typed one:

// a function that returns a function
function createMultiplier(factor: number): (value: number) => number {
  return (value) => value * factor;
}

const double = createMultiplier(2);
const triple = createMultiplier(3);

double(5);  // 10
triple(5);  // 15
Enter fullscreen mode Exit fullscreen mode

You can also define function types with type aliases, which is cleaner for complex signatures:

type EventHandler<T> = (event: T) => void;
type Middleware = (req: Request, res: Response, next: () => void) => void;
type Validator<T> = (input: unknown) => input is T; // we'll cover 'is' later
Enter fullscreen mode Exit fullscreen mode

If you've used Java's Function<T, R> or Predicate<T>, this is the same idea, just less verbose.

Generics: Same Concept, Different Syntax

You already know what generics are. A function or type that works with multiple types while keeping type safety. The concept is identical across Java, C#, Kotlin, and TypeScript. The experience of using them is not.

Basic generic function:

function first<T>(items: T[]): T | undefined {
  return items[0];
}

const num = first([1, 2, 3]);       // num is number | undefined
const str = first(["a", "b", "c"]); // str is string | undefined
Enter fullscreen mode Exit fullscreen mode

TypeScript infers T from the argument. You rarely need to specify it explicitly. Java does this too with type inference in newer versions, but TypeScript's inference is more aggressive and usually gets it right.

Here's something that tripped me up coming from Java. In Java, you might write:

public <T extends Comparable<T>> T max(List<T> items) { ... }
Enter fullscreen mode Exit fullscreen mode

The TypeScript equivalent uses structural typing (remember Post 1?). You don't need Comparable<T> as an interface. You just describe the shape:

function max<T extends { compareTo(other: T): number }>(items: T[]): T {
  return items.reduce((a, b) => (a.compareTo(b) > 0 ? a : b));
}
Enter fullscreen mode Exit fullscreen mode

No interface to implement. No implements Comparable<T>. Any object that has a compareTo method with the right signature works. Structural typing applied to generics -- it removes a whole layer of boilerplate you didn't realize was slowing you down.

One more difference: Java has wildcards (? extends T, ? super T). TypeScript doesn't. You just use extends directly in constraints. If you've ever stared at <? super Comparable<? extends T>> in Java and questioned your career choices, you'll appreciate the simplicity.

Practical Generics

From what I've seen so far, TypeScript generics show up on standalone functions far more often than on classes. Java is the opposite -- you define List<T>, Repository<T>, Service<T>. But in TypeScript, generic functions are the default.

A typed API fetcher:

async function fetchApi<T>(url: string): Promise<T> {
  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  // we're trusting the API response shape here
  return response.json() as Promise<T>;
}

// usage -- specify the type you expect back
interface User {
  id: string;
  name: string;
  email: string;
}

const user = await fetchApi<User>("/api/users/123");
// user is typed as User
console.log(user.name);
Enter fullscreen mode Exit fullscreen mode

This is one of the few cases where you specify T explicitly, because there's no argument for TypeScript to infer it from. The as Promise<T> is a type assertion -- we're telling the compiler "trust me, the API returns this shape." It's not safe at runtime, but it's a pragmatic pattern you'll see in every TypeScript codebase.

A typed event emitter:

type EventMap = {
  userCreated: { id: string; name: string };
  orderPlaced: { orderId: string; total: number };
  error: { message: string; code: number };
};

function createEmitter<Events extends Record<string, unknown>>() {
  const listeners: Partial<{
    [K in keyof Events]: Array<(payload: Events[K]) => void>;
  }> = {};

  return {
    on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void) {
      (listeners[event] ??= []).push(handler);
    },
    emit<K extends keyof Events>(event: K, payload: Events[K]) {
      listeners[event]?.forEach((fn) => fn(payload));
    },
  };
}

const emitter = createEmitter<EventMap>();

// fully typed -- TypeScript knows the payload shape
emitter.on("userCreated", (payload) => {
  console.log(payload.name); // string, autocomplete works
});

emitter.on("orderPlaced", (payload) => {
  console.log(payload.total); // number
});

// this would be a compile error:
// emitter.emit("userCreated", { orderId: "123", total: 50 });
Enter fullscreen mode Exit fullscreen mode

A generic repository pattern:

interface Entity {
  id: string;
}

function createRepository<T extends Entity>(store: Map<string, T>) {
  return {
    findById(id: string): T | undefined {
      return store.get(id);
    },
    save(entity: T): void {
      store.set(entity.id, entity);
    },
    findAll(): T[] {
      return Array.from(store.values());
    },
    delete(id: string): boolean {
      return store.delete(id);
    },
  };
}

interface Product extends Entity {
  name: string;
  price: number;
}

const productRepo = createRepository<Product>(new Map());
productRepo.save({ id: "1", name: "Keyboard", price: 79.99 });

const product = productRepo.findById("1");
// product is Product | undefined
Enter fullscreen mode Exit fullscreen mode

Notice the pattern here. In Java, you'd probably write class ProductRepository extends GenericRepository<Product>. In TypeScript, it's a function that returns an object. No class hierarchy. The generics work the same way, but the container is different.

Constrained Generics

Constraints are where generics go from "nice to have" to "actually prevents bugs." You've seen extends already, but let me show a real use case.

Say you're writing a function that merges partial updates into an entity. You want it to work with any entity that has an id:

function applyUpdate<T extends { id: string }>(
  original: T,
  update: Partial<Omit<T, "id">>
): T {
  return { ...original, ...update };
}

interface User {
  id: string;
  name: string;
  email: string;
  role: "admin" | "user";
}

const user: User = { id: "1", name: "Gabriel", email: "g@test.com", role: "user" };

const updated = applyUpdate(user, { role: "admin" });
// updated is User, with role changed to "admin"

// compile error -- can't update the id
// applyUpdate(user, { id: "2" });

// compile error -- wrong type for role
// applyUpdate(user, { role: "superadmin" });
Enter fullscreen mode Exit fullscreen mode

The constraint T extends { id: string } ensures T has an id field. Partial<Omit<T, "id">> means "all fields of T except id, and they're all optional." This is combining generics with utility types (we'll go deep on those in Post 4).

Java's bounded type parameters look like <T extends HasId> where HasId is an interface you defined. The difference: TypeScript lets you define the constraint inline. No need for a separate interface -- though you can use one if it makes the code clearer.

Type Narrowing and Type Guards

This is TypeScript's answer to the question "how do I check what type something is at runtime?" If you've used pattern matching in Kotlin (when) or even instanceof checks in Java, the concept is familiar. The mechanism is different.

typeof for primitives:

function format(value: string | number): string {
  if (typeof value === "string") {
    return value.toUpperCase(); // TypeScript knows it's string here
  }
  return value.toFixed(2); // TypeScript knows it's number here
}
Enter fullscreen mode Exit fullscreen mode

in operator for objects:

interface Dog {
  bark(): void;
}

interface Cat {
  meow(): void;
}

function makeNoise(animal: Dog | Cat): void {
  if ("bark" in animal) {
    animal.bark(); // narrowed to Dog
  } else {
    animal.meow(); // narrowed to Cat
  }
}
Enter fullscreen mode Exit fullscreen mode

Custom type guards with is:

This is the powerful one. You write a function that returns value is SomeType, and TypeScript trusts it for narrowing:

interface ApiError {
  kind: "error";
  message: string;
  statusCode: number;
}

interface ApiSuccess<T> {
  kind: "success";
  data: T;
}

type ApiResponse<T> = ApiError | ApiSuccess<T>;

// custom type guard
function isError<T>(response: ApiResponse<T>): response is ApiError {
  return response.kind === "error";
}

function handleResponse<T>(response: ApiResponse<T>): T {
  if (isError(response)) {
    // response is narrowed to ApiError
    throw new Error(`API failed: ${response.message} (${response.statusCode})`);
  }

  // response is narrowed to ApiSuccess<T>
  return response.data;
}
Enter fullscreen mode Exit fullscreen mode

This connects directly to discriminated unions from Post 2. The kind field is the discriminant, and the type guard uses it. You could also just use a switch statement on response.kind and get the same narrowing without the helper function. But is guards are useful when the checking logic is complex or reusable.

Java's instanceof does something similar after Java 16 with pattern matching:

if (response instanceof ApiError error) {
    throw new RuntimeException(error.getMessage());
}
Enter fullscreen mode Exit fullscreen mode

TypeScript's version is more flexible because it works with structural types, not class hierarchies.

The satisfies Operator

This one has no Java equivalent. It was added in TypeScript 4.9 and solves a real problem I kept running into. The problem it solves: you want to verify that a value matches a type, but you don't want to lose the specific literal types.

Without satisfies:

type Route = {
  path: string;
  method: "GET" | "POST" | "PUT" | "DELETE";
};

// if you annotate the type, you lose the specific literal types
const routes: Record<string, Route> = {
  getUser: { path: "/users/:id", method: "GET" },
  createUser: { path: "/users", method: "POST" },
};

// routes.getUser works, but TypeScript only knows it's Route
// routes.nonExistent also compiles -- Record<string, Route> allows any key
Enter fullscreen mode Exit fullscreen mode

With satisfies:

const routes = {
  getUser: { path: "/users/:id", method: "GET" },
  createUser: { path: "/users", method: "POST" },
} satisfies Record<string, Route>;

// TypeScript validates it matches Record<string, Route>
// BUT it keeps the narrow type -- routes only has getUser and createUser
routes.getUser.method; // type is "GET", not "GET" | "POST" | "PUT" | "DELETE"

// compile error -- TypeScript knows this key doesn't exist
// routes.nonExistent;
Enter fullscreen mode Exit fullscreen mode

Real use case: configuration objects. I use this all the time.

type DbConfig = {
  host: string;
  port: number;
  database: string;
  ssl: boolean;
};

const config = {
  development: {
    host: "localhost",
    port: 5432,
    database: "myapp_dev",
    ssl: false,
  },
  production: {
    host: "db.prod.internal",
    port: 5432,
    database: "myapp",
    ssl: true,
  },
} satisfies Record<string, DbConfig>;

// TypeScript knows exactly which keys exist
// config.development -- works
// config.staging -- compile error, no such key
// And every value is validated against DbConfig
Enter fullscreen mode Exit fullscreen mode

Think of satisfies as "check this value, but don't widen its type."

Overloads in TypeScript

They exist. They work completely differently than Java.

Java overloading means multiple method bodies with different parameter lists. The compiler picks the right one based on the call site:

public String format(String value) { return value; }
public String format(int value) { return String.valueOf(value); }
public String format(Date value) { return value.toString(); }
Enter fullscreen mode Exit fullscreen mode

TypeScript overloads are just type signatures. There's only one implementation body, and you handle the different cases yourself:

// overload signatures (no body)
function format(value: string): string;
function format(value: number): string;
function format(value: Date): string;

// implementation signature (has the body)
function format(value: string | number | Date): string {
  if (typeof value === "string") return value;
  if (typeof value === "number") return value.toFixed(2);
  return value.toISOString();
}

format("hello"); // return type is string
format(42);      // return type is string
format(new Date()); // return type is string
Enter fullscreen mode Exit fullscreen mode

The overload signatures tell callers what combinations of parameters and return types are valid. The implementation signature is wider -- it accepts the union of all overload parameter types. Callers never see the implementation signature directly.

Here's a more practical example where overloads actually earn their keep:

// different return types based on a flag
function fetchUser(id: string, raw: true): Promise<Response>;
function fetchUser(id: string, raw?: false): Promise<User>;
async function fetchUser(id: string, raw?: boolean): Promise<Response | User> {
  const response = await fetch(`/api/users/${id}`);
  if (raw) return response;
  return response.json() as Promise<User>;
}

const user = await fetchUser("123");       // Promise<User>
const raw = await fetchUser("123", true);  // Promise<Response>
Enter fullscreen mode Exit fullscreen mode

My honest opinion: prefer union types over overloads most of the time. Overloads are great when the return type changes based on the input. For everything else, a union parameter is simpler, easier to read, and easier to maintain. I've seen codebases with 15 overload signatures on a single function, and it's miserable to debug.

What Stuck With Me

Functions as values, not methods bolted onto classes. Generics on standalone functions instead of class hierarchies. Type guards that narrow at the compiler level. satisfies that validates without widening. Overloads that exist but rarely deserve to be your first choice.

The thing I keep coming back to: the generic repository pattern that takes 4 files and 2 interfaces in Java is a single function in TypeScript. Not because TypeScript is better -- it made different trade-offs. Less ceremony, more inference, structural checks instead of nominal contracts.

Post 4 will cover utility types: Partial, Pick, Omit, Record, mapped types, conditional types, and infer. That's where TypeScript's type system goes from "familiar" to "wait, you can do that?"


What tripped you up the most moving from Java/C#/Kotlin generics to TypeScript generics? Or if you're still in the middle of it, what's confusing you right now? Drop a comment -- I'm still figuring some of this out too.

I'm building Hermes IDE, an open-source AI-powered dev tool built with TypeScript and Rust. If you want to see these patterns in a real codebase, check it out on GitHub. A star helps a lot. You can follow my work at gabrielanhaia.

Top comments (0)