DEV Community

Cover image for TypeScript for Java/PHP Devs: What's Different and What's Familiar
Gabriel Anhaia
Gabriel Anhaia

Posted on

TypeScript for Java/PHP Devs: What's Different and What's Familiar


In Java, Dog and Animal are different types even if they have the same fields. In TypeScript, they're the same type. This will break your brain for about a day.

I spent years writing backend code in Java, Kotlin, and PHP. When I started picking up TypeScript, I expected the usual learning curve: new syntax, a different standard library, maybe some quirky tooling. The syntax was fine. The standard library was fine. What got me was how differently the type system thinks.

This is the first post in a 7-part series where I go through TypeScript from the perspective of someone who already knows typed languages. I won't explain why types matter. You already know. Instead, I'll focus on the stuff that actually confused me, the patterns that don't have equivalents in Java or C#, and the things I wish someone had told me up front.

What's Already Familiar

Most of the building blocks you already know exist in TypeScript. The syntax differs, but the concepts map pretty directly.

Variable declarations with types:

// Java
String name = "Gabriel";
int age = 30;
boolean active = true;
Enter fullscreen mode Exit fullscreen mode
// TypeScript
const name: string = "Gabriel";
const age: number = 30;
const active: boolean = true;
Enter fullscreen mode Exit fullscreen mode

Interfaces:

// Java
public interface UserRepository {
    User findById(String id);
    List<User> findAll();
}
Enter fullscreen mode Exit fullscreen mode
// TypeScript
interface UserRepository {
  findById(id: string): User;
  findAll(): User[];
}
Enter fullscreen mode Exit fullscreen mode

Generics:

// Kotlin
class Repository<T>(private val items: MutableList<T> = mutableListOf()) {
    fun add(item: T) { items.add(item) }
    fun getAll(): List<T> = items.toList()
}
Enter fullscreen mode Exit fullscreen mode
// TypeScript
class Repository<T> {
  private items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

  getAll(): T[] {
    return [...this.items];
  }
}
Enter fullscreen mode Exit fullscreen mode

Enums (click to expand)
// Java
public enum Status {
    ACTIVE, INACTIVE, PENDING
}
Enter fullscreen mode Exit fullscreen mode
// TypeScript
enum Status {
  Active = "ACTIVE",
  Inactive = "INACTIVE",
  Pending = "PENDING",
}
Enter fullscreen mode Exit fullscreen mode

If you're coming from any of these languages, the surface-level stuff won't slow you down. The type annotations go after the variable name instead of before it, arrays use T[] instead of List<T>, and void works essentially the same way. You'll adjust in an afternoon.

The real differences are deeper than syntax.

Mental Shift #1: Structural Typing

This one rewired how I think about types.

In Java, C#, and PHP, types are nominal. Two classes with identical fields are different types unless they explicitly share an interface or inheritance chain. The name of the type is what matters.

// Java — these are NOT interchangeable
public class Dog {
    public String name;
    public int age;
}

public class Cat {
    public String name;
    public int age;
}

// This won't compile, even though Dog and Cat have the exact same fields
Dog dog = new Cat(); // ERROR
Enter fullscreen mode Exit fullscreen mode

TypeScript doesn't care about names. It cares about shape.

interface Dog {
  name: string;
  age: number;
}

interface Cat {
  name: string;
  age: number;
}

function greetPet(pet: Dog): string {
  return `Hello, ${pet.name}!`;
}

const myCat: Cat = { name: "Whiskers", age: 3 };

// This works perfectly. Cat has the same shape as Dog.
greetPet(myCat); // No error
Enter fullscreen mode Exit fullscreen mode

Dog and Cat are interchangeable because they have the same properties with the same types. TypeScript doesn't check if Cat implements Dog or extends it. It just looks at the structure and says "close enough."

This goes further than you'd expect. You don't even need to declare a type at all:

function greetPet(pet: { name: string; age: number }): string {
  return `Hello, ${pet.name}!`;
}

// Any object with name and age works
greetPet({ name: "Rex", age: 5 }); // fine
Enter fullscreen mode Exit fullscreen mode

When I first encountered this coming from Java, I thought it was a bug. I'd carefully defined separate interfaces for CreateUserRequest and UpdateUserRequest, and then the compiler happily accepted one where the other was expected because they happened to have the same fields. No warning. No error.

It feels wrong at first. In Java, the type hierarchy is your safety net. You want the compiler to reject a Cat where a Dog is expected, even if they look the same. TypeScript's position is different: if it has the right shape, it can do the job.

You'll stop fighting this after a few days, but expect some disorientation until it clicks.

Mental Shift #2: Types Disappear at Runtime

This one has practical consequences that'll bite you if you're not ready.

TypeScript compiles down to JavaScript. When it does, every type annotation, interface, and type alias gets erased. Gone. The runtime has no idea they ever existed.

Here's a TypeScript file:

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

function isAdmin(user: User): boolean {
  return user.role === "admin";
}
Enter fullscreen mode Exit fullscreen mode

After compilation, this is what actually runs:

"use strict";
function isAdmin(user) {
  return user.role === "admin";
}
Enter fullscreen mode Exit fullscreen mode

The User interface is completely gone. It was never real — it only existed to help the compiler check your code.

This means you cannot do instanceof checks on interfaces:

interface Admin {
  permissions: string[];
}

interface Viewer {
  viewOnly: boolean;
}

function checkAccess(user: Admin | Viewer) {
  // This won't work. Interfaces don't exist at runtime.
  if (user instanceof Admin) { // ERROR: 'Admin' only refers to a type
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

If you're coming from Java or C#, where you'd naturally reach for instanceof or pattern matching on types, this feels limiting. And it is, genuinely. There's no runtime type reflection. You can't inspect an object and ask "are you an Admin?" because the concept of Admin doesn't survive compilation.

The workaround is discriminated unions — you add a literal field that acts as a runtime tag:

interface Admin {
  kind: "admin"; // this string literal exists at runtime
  permissions: string[];
}

interface Viewer {
  kind: "viewer";
  viewOnly: boolean;
}

type AppUser = Admin | Viewer;

function checkAccess(user: AppUser) {
  if (user.kind === "admin") {
    // TypeScript narrows the type here — user is Admin
    console.log(user.permissions);
  }
}
Enter fullscreen mode Exit fullscreen mode

The kind field is a regular string that survives compilation. TypeScript's compiler is smart enough to narrow the type based on it. It works, but it's a pattern you have to build yourself. The language doesn't give it to you for free like sealed classes in Kotlin.

This type erasure also means no Spring-style dependency injection. Frameworks like Spring, Laravel, and .NET rely on runtime type information to wire things together. TypeScript can't do that. DI frameworks in the TS world (like tsyringe or InversifyJS) work around it using decorators and metadata, but it's never as automatic as @Autowired.

Mental Shift #3: null and undefined

In Java, you have null. In PHP, you have null. One value, one concept: "nothing is here."

TypeScript has two: null and undefined. They're different values with different semantics, and you'll encounter both constantly.

let a: string | null = null;       // explicitly set to "no value"
let b: string | undefined;         // declared but never assigned
let c: string | undefined = undefined; // explicitly set to... also "no value"

console.log(a); // null
console.log(b); // undefined
console.log(c); // undefined
Enter fullscreen mode Exit fullscreen mode

When does each show up?

  • undefined: uninitialized variables, missing object properties, function parameters you didn't pass, void function return values
  • null: almost always from your code or APIs that explicitly use it. Database query returns? Often null. DOM operations? null. JSON from external services? Could be either.

In practice, undefined is the language's default "absent" value, and null is the programmer's intentional "empty" value. But plenty of codebases use them interchangeably, and you'll find both in the wild.

Since TypeScript 6.0, strict mode defaults to true in new projects. That means strictNullChecks is on by default. The compiler won't let you use a value that might be null or undefined without checking first:

function getUsername(user: { name?: string }): string {
  // Won't compile — user.name could be undefined
  return user.name.toUpperCase(); // ERROR: Object is possibly 'undefined'
}

// You have to handle it
function getUsername(user: { name?: string }): string {
  if (user.name === undefined) {
    return "Anonymous";
  }
  return user.name.toUpperCase(); // now TypeScript knows it's a string
}
Enter fullscreen mode Exit fullscreen mode

Coming from Kotlin's null safety system, this felt familiar but less elegant. Kotlin's ?. and ?: operators are built into the language. TypeScript has optional chaining (?.) and nullish coalescing (??) which do the same thing, but you're always juggling two absent values instead of one:

// optional chaining + nullish coalescing
const displayName = user.profile?.name ?? "Anonymous";
Enter fullscreen mode Exit fullscreen mode

My advice: pick one. Most TypeScript codebases I've worked in lean toward undefined for "no value" and avoid null unless an external API forces it. Consistency matters more than the specific choice.

Mental Shift #4: No Real Method Overloading

Java developers love method overloading. Same method name, different parameter lists, the compiler picks the right one:

// Java — real overloading
public class Formatter {
    public String format(String input) {
        return input.trim();
    }

    public String format(String input, int maxLength) {
        return input.substring(0, Math.min(input.length(), maxLength)).trim();
    }

    public String format(String input, String prefix) {
        return prefix + ": " + input.trim();
    }
}
Enter fullscreen mode Exit fullscreen mode

TypeScript has something called "overload signatures." It looks similar. It isn't.

// TypeScript — overload signatures
function format(input: string): string;
function format(input: string, maxLength: number): string;
function format(input: string, prefix: string): string;

// The actual implementation — ONE function handles all cases
function format(input: string, secondArg?: number | string): string {
  if (typeof secondArg === "number") {
    return input.substring(0, secondArg).trim();
  }
  if (typeof secondArg === "string") {
    return `${secondArg}: ${input.trim()}`;
  }
  return input.trim();
}
Enter fullscreen mode Exit fullscreen mode

The overload signatures are just type declarations. They tell the compiler "these are the valid ways to call this function." But there's only one actual implementation, and you have to write the branching logic yourself with type guards.

It's manual dispatch wearing a trenchcoat pretending to be overloading.

This tripped me up because I kept trying to write separate implementations for each signature. That's not how it works. You get one function body. You check the types of your arguments at runtime (with typeof, instanceof on classes, or custom checks) and branch accordingly.

For simple cases, union types are usually cleaner than pretending to overload:

// often simpler to just be explicit about what you accept
function format(input: string, option?: number | string): string {
  if (typeof option === "number") {
    return input.substring(0, option).trim();
  }
  if (typeof option === "string") {
    return `${option}: ${input.trim()}`;
  }
  return input.trim();
}
Enter fullscreen mode Exit fullscreen mode

Save overload signatures for when you need the call-site types to be precise, like when the return type changes based on the input type. We'll cover those patterns in Post 3.

Quick TypeScript 6.0 Setup

Enough concepts. Let's set up a project.

mkdir my-ts-project && cd my-ts-project
npm init -y
npm install typescript@latest
npx tsc --init
Enter fullscreen mode Exit fullscreen mode

That last command generates a tsconfig.json. In TypeScript 6.0, the defaults changed significantly from 5.x. Here's what you'll see (simplified, with only the most relevant options):

{
  "compilerOptions": {
    // Defaults to true in 6.0 — enables all strict type checks
    // In 5.x you had to turn this on yourself. Now it's the default.
    "strict": true,

    // Defaults to "esnext" — outputs modern JS with latest features
    "module": "esnext",

    // Defaults to "es2025" — target runtime supports modern APIs
    "target": "es2025",

    // You'll want to set this yourself for Node.js projects
    // "nodenext" is the recommended choice. The old "node" value is deprecated.
    "moduleResolution": "nodenext",

    // Where compiled JS files go
    "outDir": "./dist",

    // Generate .d.ts type declaration files
    "declaration": true
  },
  "include": ["src/**/*"]
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting about the 6.0 changes (verify against the release notes if you're reading this later):

strict: true is now the default. In 5.x, you had to opt in. Now every new project starts strict. This is the right call. strictNullChecks, noImplicitAny, and the rest should have been defaults from the start.

target defaults to es2025. ES5 output is deprecated. If you're targeting ancient runtimes, you'll need a separate transpiler. For Node.js 20+, es2025 is exactly right.

moduleResolution: "node" is deprecated. Use "nodenext" for Node.js projects or "bundler" if you're using Vite, webpack, or similar. The old "node" resolution had quirks with ESM that the newer modes fix.

AMD, UMD, and SystemJS module formats are gone. If those mean nothing to you, good. They're relics of a pre-ESM world.

Create a quick test file to make sure things work:

mkdir src
Enter fullscreen mode Exit fullscreen mode
// src/index.ts
interface Config {
  port: number;
  host: string;
  debug: boolean;
}

const config: Config = {
  port: 3000,
  host: "localhost",
  debug: true,
};

console.log(`Server starting on ${config.host}:${config.port}`);
Enter fullscreen mode Exit fullscreen mode
npx tsc
node dist/index.js
# Output: Server starting on localhost:3000
Enter fullscreen mode Exit fullscreen mode

That's it. No Spring Boot starter project. No Composer autoload setup. TypeScript's tooling is refreshingly minimal.

What's Coming Next

This post covered the big mental shifts: structural typing, type erasure, the null/undefined split, and overloading that isn't really overloading. These are the things that tripped me up most in the first week, and they're the foundation for everything else.

In Post 2, we'll get into the type system itself: union types, intersection types, literal types, type narrowing, and the stuff that has no equivalent in Java or C#. That's where TypeScript starts feeling like a genuinely different language rather than "Java with different syntax."


What tripped you up most when you switched to TypeScript? Structural typing? Type erasure? Something else entirely? I'd like to hear about it in the comments.

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)