- My project: Hermes IDE | GitHub
- Me: gabrielanhaia
The biggest mistake Java devs make in TypeScript is writing Java. TypeScript rewards a different style, and a real project is where that becomes obvious.
Over the last six posts, we've gone through the type system, generics, utility types, modules, async patterns, and error handling. All the pieces are there. But knowing TypeScript features in isolation isn't the same as knowing how to set up an actual project that you'd be comfortable shipping to production.
This is the capstone. We're building a modern TypeScript project from scratch, the way you'd actually do it in 2026. No create-whatever-app. No boilerplate generators. No magic. Just you, npm, and a terminal.
Project Scaffold from Scratch
mkdir ts-service && cd ts-service
npm init -y
npm install typescript
npx tsc --init
Four commands. You now have a TypeScript project. Compare that to generating a Spring Boot project: you need Spring Initializr or Maven archetypes, you get a pom.xml the size of a novel, and your source code lives at src/main/java/com/company/project/service/impl/. By the time you find where to write code, you've forgotten what you wanted to build.
TypeScript projects are flat. A typical structure looks like this:
ts-service/
src/
routes/
users.ts
orders.ts
services/
user-service.ts
order-service.ts
types/
user.ts
order.ts
common.ts
utils/
validation.ts
errors.ts
index.ts
tests/
services/
user-service.test.ts
order-service.test.ts
utils/
validation.test.ts
dist/ # compiled output, gitignored
tsconfig.json
eslint.config.js
vitest.config.ts
package.json
No src/main vs src/test. No com.company.project package nesting. Files live where they make sense. A types/ folder for shared type definitions. A services/ folder for business logic. Tests mirror the source tree. That's it.
There's no strict convention here. Some teams put tests next to source files (user-service.ts and user-service.test.ts in the same directory). Some use lib/ instead of src/. The ecosystem is less opinionated about structure than Java or C#. Pick something reasonable and be consistent.
mkdir -p src/{routes,services,types,utils} tests/{services,utils}
The Modern tsconfig.json
The tsconfig.json is where most backend devs get confused. There are dozens of options, and the defaults changed significantly in TypeScript 6.0. This is the config I use for Node.js backend apps, with the important options explained:
{
"compilerOptions": {
// --- Type Checking ---
// Enables all strict type-checking flags. Default in TS 6.0.
// Includes strictNullChecks, noImplicitAny, strictFunctionTypes, etc.
// If you're tempted to turn this off: don't.
"strict": true,
// Verifies that side-effect-only imports (import "./setup.js") actually resolve.
// Without this, a typo in a side-effect import silently does nothing.
"noUncheckedSideEffectImports": true,
// Catch unused locals and parameters.
// Annoying during prototyping, valuable in production code.
"noUnusedLocals": true,
"noUnusedParameters": true,
// Force functions with code paths that don't all return a value to error.
"noImplicitReturns": true,
// --- Module System ---
// "nodenext" is the correct choice for Node.js in 2026.
// Supports both ESM and CJS. Understands package.json "exports".
// The old "node" value is deprecated.
"module": "nodenext",
"moduleResolution": "nodenext",
// --- Emit ---
// Modern JS output. Node 22+ supports everything in es2025.
"target": "es2025",
// Compiled JS goes here. Add dist/ to .gitignore.
"outDir": "./dist",
// Your source root. Preserves directory structure in outDir.
"rootDir": "./src",
// Generate .d.ts files alongside JS output.
// Essential if you're building a library. Harmless for applications.
"declaration": true,
// Source maps for debugging. Your stack traces will point to .ts files
// instead of compiled .js, which makes production debugging bearable.
"sourceMap": true,
// --- Interop ---
// Allows default imports from modules with no default export.
// Mostly needed for compatibility with CommonJS packages.
"esModuleInterop": true,
// Resolve JSON files as modules. Useful for config files.
"resolveJsonModule": true,
// Ensure import casing matches the filesystem.
// Saves you from "works on Mac, breaks on Linux" issues.
"forceConsistentCasingInFileNames": true
},
// What to compile
"include": ["src/**/*"],
// What to ignore
"exclude": ["node_modules", "dist", "tests"]
}
A few things that catch backend devs off guard:
module and moduleResolution are separate things. module controls what module syntax TypeScript emits (ESM import/export vs CJS require/module.exports). moduleResolution controls how TypeScript finds the file when you write import { foo } from "./bar". They're related but independent. With nodenext, both follow Node.js's actual resolution algorithm, which is what you want.
rootDir matters more than you'd think. Without it, TypeScript might flatten your output directory structure in unexpected ways. Set it to ./src and your src/services/user-service.ts compiles to dist/services/user-service.js. Clean, predictable.
declaration: true is optional for apps. If you're building a service that nobody imports, you don't technically need .d.ts files. I leave it on anyway because it catches certain categories of type errors that tsc alone misses, and the overhead is negligible.
Library vs Application Config
If you're building a library (an npm package others will install), your config looks different:
{
"compilerOptions": {
"strict": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2022", // target a broader range of runtimes
"outDir": "./dist",
"rootDir": "./src",
"declaration": true, // required: consumers need your types
"declarationMap": true, // lets consumers "Go to Definition" into your .ts source
"sourceMap": true,
"composite": true // required for project references
},
"include": ["src/**/*"]
}
The main differences: target drops to es2022 for wider compatibility, declaration is mandatory (not optional), and declarationMap lets consumers of your library jump to your actual TypeScript source instead of the generated .d.ts file. If you're publishing to npm, those details matter.
ESLint + TypeScript in 2026
If you've ever set up Checkstyle or PMD in a Java project, ESLint fills the same role. Static analysis, code style enforcement, catching common mistakes.
ESLint moved to flat config files in v9, and by 2026 the legacy .eslintrc format is gone. Everything lives in eslint.config.js:
npm install --save-dev eslint @eslint/js typescript-eslint
// eslint.config.js
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
// Base ESLint recommended rules
eslint.configs.recommended,
// TypeScript-specific rules that require type information
...tseslint.configs.strictTypeChecked,
// Stylistic rules (optional, but I like them)
...tseslint.configs.stylisticTypeChecked,
// Tell the TS parser where to find your tsconfig
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
// Overrides for test files: loosen rules where strictness adds noise
{
files: ["tests/**/*.ts"],
rules: {
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-explicit-any": "off",
},
},
// Ignore compiled output
{
ignores: ["dist/"],
}
);
Add scripts to your package.json:
{
"scripts": {
"lint": "eslint src/ tests/",
"lint:fix": "eslint src/ tests/ --fix"
}
}
That's the whole setup. No XML config files. No plugin compatibility matrices.
The strictTypeChecked preset is opinionated. It'll flag things like floating promises (async calls you forgot to await), unsafe any usage, and incorrect type assertions. If it's too aggressive for your taste, swap it for recommendedTypeChecked. Same idea, fewer rules.
One thing the Java linting world does better: IDE integration is more automatic. With ESLint, make sure your editor has the ESLint extension installed and configured. VS Code handles it well. Other editors vary.
Testing with Vitest
For years, Jest was the default testing framework for TypeScript projects. It worked, but the experience was rough: you needed ts-jest or @swc/jest to handle TypeScript compilation, configuration was fiddly, and ESM support was bolted on as an afterthought.
Vitest replaced all of that for me. It understands TypeScript natively, runs ESM without configuration hacks, and it's fast. Really fast. If you're coming from JUnit or PHPUnit, the API will feel familiar: describe, it, expect, assertions.
npm install --save-dev vitest
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// Where your tests live
include: ["tests/**/*.test.ts"],
// Coverage configuration
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
exclude: ["src/types/**"],
},
},
});
Add the test scripts:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
Now let's write an actual test. Say you have this service:
// src/services/user-service.ts
import type { User, CreateUserInput } from "../types/user.js";
type UserResult =
| { success: true; user: User }
| { success: false; error: string };
export function createUser(input: CreateUserInput): UserResult {
if (!input.email.includes("@")) {
return { success: false, error: "Invalid email address" };
}
if (input.name.trim().length === 0) {
return { success: false, error: "Name cannot be empty" };
}
const user: User = {
id: crypto.randomUUID(),
name: input.name.trim(),
email: input.email.toLowerCase(),
createdAt: new Date(),
};
return { success: true, user };
}
The test:
// tests/services/user-service.test.ts
import { describe, it, expect } from "vitest";
import { createUser } from "../../src/services/user-service.js";
describe("createUser", () => {
it("creates a user with valid input", () => {
const result = createUser({
name: "Gabriel",
email: "gabriel@example.com",
});
// TypeScript knows result is the union type.
// After this assertion, the type narrows.
expect(result.success).toBe(true);
if (result.success) {
// Now TypeScript knows result.user exists
expect(result.user.name).toBe("Gabriel");
expect(result.user.email).toBe("gabriel@example.com");
expect(result.user.id).toBeDefined();
}
});
it("rejects invalid email", () => {
const result = createUser({
name: "Gabriel",
email: "not-an-email",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe("Invalid email address");
}
});
it("rejects empty name", () => {
const result = createUser({
name: " ",
email: "gabriel@example.com",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe("Name cannot be empty");
}
});
});
Notice the if (result.success) guards inside the tests. You need those because result is a discriminated union. You could also use a type assertion or Vitest's expect chaining, but I prefer the guards. They're explicit and the compiler verifies them.
In JUnit, you'd just cast and hope. In PHPUnit, you'd assert and move on. TypeScript's type narrowing works inside test files too, and it catches real bugs. I've had tests that passed at runtime but had incorrect type assumptions that would've hidden regressions. The compiler caught what the test runner wouldn't.
Run it:
npx vitest run
✓ tests/services/user-service.test.ts (3 tests) 2ms
Test Files 1 passed (1)
Tests 3 passed (3)
Start at 14:23:07
Duration 312ms
312 milliseconds. Try that with a Spring test context startup.
The Build Step: When You Need It and When You Don't
Something that confused me early on: do you actually need to run tsc to use TypeScript?
In 2026, the answer depends on what you're doing.
For development and scripts: run TypeScript directly. Node.js 23+ has native TypeScript execution via the --experimental-strip-types flag. Node 24 makes it stable. You can also use tsx, which has been the community standard for a while:
# Native Node.js (v24+, stable)
node src/index.ts
# Or with tsx (any Node version)
npm install --save-dev tsx
npx tsx src/index.ts
Both approaches strip the types and execute the JavaScript directly. No compilation step. No dist/ folder. You edit a .ts file and run it immediately. For development, local scripts, and CLI tools, this is all you need.
For production: compile with tsc. When you deploy, you want compiled JavaScript. Reasons:
- Startup time. Type stripping adds overhead on every cold start. For a Lambda function or a containerized service that scales from zero, that matters.
-
Validation.
tsccatches type errors that runtime strippers ignore.tsxand Node's native TS execution don't type-check. They literally just remove type annotations. You wanttscto verify your types before you ship. - Compatibility. Some deployment targets and serverless platforms don't support TypeScript execution directly.
The production build setup in package.json:
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"lint": "eslint src/ tests/",
"test": "vitest run",
"test:watch": "vitest",
"ci": "npm run lint && npm run test && npm run build"
}
}
The dev script uses tsx watch, which restarts on file changes. The build script compiles with tsc for deployment. The ci script runs everything in sequence for your CI pipeline.
That ci script order is deliberate. Lint first (fastest feedback). Then tests. Then build. If linting fails, you don't waste time running the test suite. If tests fail, you don't waste time compiling.
Common Mistakes Backend Devs Make in TypeScript
I made every single one of these. Some took me weeks to unlearn.
Over-using classes
This is the big one. You come from Java or C#, so your instinct is to model everything as a class. UserService class. OrderRepository class. PaymentProcessor class. Each with a constructor, private fields, and methods.
TypeScript works differently. The ecosystem leans functional. Most of the time, a function and a type do the job better than a class:
// The Java instinct
class UserService {
private readonly repository: UserRepository;
constructor(repository: UserRepository) {
this.repository = repository;
}
async getUser(id: string): Promise<User | null> {
return this.repository.findById(id);
}
async createUser(input: CreateUserInput): Promise<User> {
// validation, creation, etc.
return this.repository.save(input);
}
}
// What you'd actually write in TypeScript
type UserRepository = {
findById(id: string): Promise<User | null>;
save(input: CreateUserInput): Promise<User>;
};
function makeUserService(repo: UserRepository) {
return {
getUser: (id: string) => repo.findById(id),
createUser: (input: CreateUserInput) => repo.save(input),
};
}
The function version is shorter, has no this binding headaches, and the return type is inferred. You don't need new. You don't need to worry about whether methods are bound correctly when passed as callbacks. TypeScript's structural typing means the returned object satisfies any interface that matches its shape.
Classes aren't wrong. They're fine for things with complex internal state or when you genuinely need instanceof checks at runtime. But reaching for them by default is a Java habit, not a TypeScript one.
Fighting the type system instead of using it
When any creeps into your codebase, it's usually a design problem, not a type system limitation. I've seen backend devs cast things to any because they couldn't figure out the right type. Every time I dug into those cases, there was a proper solution. A generic, a discriminated union, a utility type.
// "I can't figure out the type, so..."
function processItems(items: any[]) {
items.forEach((item: any) => {
// now you've lost all type safety
doSomething(item.maybeName);
});
}
// Five minutes of thought later
function processItems<T extends { name: string }>(items: T[]) {
items.forEach((item) => {
// fully typed, autocomplete works
doSomething(item.name);
});
}
If you're reaching for any, stop and ask what the actual type is. In six months of TypeScript, I've needed any exactly twice, both in test fixtures. If you're using it more than that, something's off.
Writing AbstractFactoryProviderImpl
I'll just leave this here:
// Please don't
abstract class AbstractNotificationProvider {
abstract send(message: string, recipient: string): Promise<void>;
}
class EmailNotificationProvider extends AbstractNotificationProvider {
async send(message: string, recipient: string): Promise<void> {
// send email
}
}
class SmsNotificationProvider extends AbstractNotificationProvider {
async send(message: string, recipient: string): Promise<void> {
// send SMS
}
}
class NotificationProviderFactory {
create(type: "email" | "sms"): AbstractNotificationProvider {
switch (type) {
case "email":
return new EmailNotificationProvider();
case "sms":
return new SmsNotificationProvider();
}
}
}
Versus:
// This is the same thing
type NotificationSender = (message: string, recipient: string) => Promise<void>;
const senders: Record<string, NotificationSender> = {
email: async (message, recipient) => { /* send email */ },
sms: async (message, recipient) => { /* send SMS */ },
};
function sendNotification(type: keyof typeof senders, message: string, recipient: string) {
return senders[type](message, recipient);
}
Four classes and an abstract base became a type alias, an object literal, and a function — that's it. The type safety is equivalent. The readability is better. You don't need to trace through an inheritance hierarchy to understand what happens when someone calls sendNotification("email", ...).
Ignoring strict mode
TypeScript 6.0 defaults to strict: true. If you're on an older codebase and you've been running with strict: false (or worse, explicitly disabling individual strict flags), you're opting out of the best parts of the language. strictNullChecks alone catches a category of bugs that no amount of testing reliably finds. noImplicitAny forces you to think about types instead of letting any silently infect your codebase.
Turning on strict mode in an existing project is painful. Every strict flag you enable will surface dozens of errors. But those errors were always there. The compiler is just showing you bugs you already had. I've seen production crashes traced back to nullable values that strictNullChecks would've caught at compile time. Fix them once, never worry again.
Not using discriminated unions
We covered this in Post 2, but I want to hammer it one more time because it's that important. When you have a piece of data that can be in different states, your Java brain says "create an abstract base class." Your TypeScript brain should say "create a discriminated union."
API response that's either success or failure? Union. Payment that's pending, authorized, captured, or failed? Union. Form validation result with different error shapes? Union.
They serialize to JSON without ceremony. They narrow in switch statements. They get exhaustiveness checking for free. And they don't require new, class, or extends. If you only change one habit from this series, make it this one.
Where to Go Next
You've got the foundation. Some directions worth exploring:
The TypeScript Handbook. The official handbook is good but long. Focus on these chapters:
- Narrowing: the deepest treatment of type narrowing, more than we covered here
- Generics: worth re-reading after you've used generics in anger for a few weeks
- Type Manipulation: mapped types, conditional types, template literal types, all in one place
Open source codebases with good TypeScript patterns. Reading well-typed code teaches you more than any tutorial:
- tRPC: end-to-end type safety between client and server. Wild use of generics and inference.
- Zod: schema validation with type inference. The source code is one of the best examples of conditional types you'll find.
- Hono: lightweight web framework with genuinely type-safe routing. Good model for how TS backend frameworks work.
- Effect: typed functional effects. Advanced, but if you want to see how far TypeScript's type system can go, this is it.
Contributing. One of the fastest ways to level up is to open PRs on TypeScript projects. Start with type improvements: better generics, removing any, adding discriminated unions where the codebase uses type assertions. Maintainers love those PRs. You'll learn the patterns by applying them to real code.
Series Wrap-Up
Seven posts. We went from "TypeScript has structural typing?" to setting up a production-ready project. Quick map if you want to revisit anything:
- What's Different: Structural typing, type erasure, null vs undefined
- Type System: Unions, discriminated unions, enums, unknown vs any
- Functions & Generics: Overloads, inference, constraints, type parameters
- Utility Types: Partial, Pick, Omit, mapped types, conditional types, template literals
- Modules & npm: ESM vs CJS, module resolution, package.json setup
- Async & Errors: Promises, error handling, Result pattern, Zod, Temporal
- Project Setup: This post. Putting it all together.
The biggest shift for me wasn't learning new syntax. It was letting go of patterns that worked well in Java but don't fit TypeScript's model — and being okay with that. Classes as the default unit of abstraction. Deep inheritance hierarchies. Nominal thinking about types. Once I stopped translating Java into TypeScript syntax and started writing TypeScript, everything clicked.
That doesn't mean Java patterns are wrong. They're right for Java. TypeScript is a different language with different strengths, and the sooner you lean into those strengths, the more productive you'll be.
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.
What tripped you up most coming from a backend language? What do you wish this series had covered? Drop a comment. I genuinely want to know.
Top comments (0)