Organizing TypeScript code: files, modules, namespaces, bundling
TypeScript gives you multiple ways to split code across files. In modern projects, ES Modules (import/export) are the default standard. Namespaces + /// <reference .../> are mostly legacy and are mainly used in older codebases or very specific scenarios.
You listed three approaches:
- Multiple TS files + manual script imports Works, but you can easily lose type safety and correct load order at runtime.
- Namespaces TS groups code into a global-ish container (compiled output uses an IIFE pattern to simulate namespacing).
- Bundling Combine many files into one output file to reduce script management.
Namespaces + triple-slash references (legacy approach)
Declaring a namespace in a file
//drag-drop-interfaces.ts
namespace DDInterfaces{
// compiled into an object, stored as properties.
//Access to properties taken care of by typescript.
export interface 1{}
export interface 2{}
// now available outside as well.
}Conceptually:
- Everything inside
namespace DDInterfaces { ... }is grouped. exportexposes members outside the namespace.- At runtime, TS emits JS that creates/extends an object-like namespace (via IIFE patterns), so you can access things via dot-notation.
Pulling in referenced files
//app.ts:
///< reference path ="drag-drop-interfaces.ts"/>This tells TypeScript: “include that file in the compilation context.”
Important limitation:
- This is not like ES module imports. It doesn’t automatically solve runtime loading unless you bundle output properly or load scripts in the right order.
Keeping everything under one shared namespace
You noted that you still need the code in the same namespace if you want to refer to it consistently:
//drag-drop-interfaces.ts
namespace App{
// compiled into an object, stored as properties.
//Access to properties taken care of by typescript.
export interface 1{}
export interface 2{}
// now available outside as well.
}//app.ts:
///< reference path ="drag-drop-interfaces.ts"/>
namespace App{
// whole app here.
}So the pattern is:
- Multiple files declare
namespace App { ... } /// <reference .../>wires the files together for the TS compiler- The output JS relies on correct ordering (or bundling into a single file)
Bundling namespaces via outFile + AMD
You wrote:
- enable
outFileintsconfig(e.g../dist/bundle.js) - switch
moduletoamd - this produces a single
bundle.jsinstead of multiple output files
That matches how TS “concatenation bundling” works: outFile only works for certain module formats (classic SystemJS/AMD-era workflows). In modern ES module setups, outFile is generally not used; bundlers handle bundling instead.
Practical problems with namespaces
Folder organization becomes annoying
When you move files into folders like models/, decorators/, etc. you must:
- update reference paths
- keep exports correct
You also noted a crucial rule:
“all things within files need to be exported to be used in other files.”
That’s true for namespaces too: if it’s not exported from the namespace block, it’s not visible outside it.
Load order matters (and can fail silently)
This is one of the biggest footguns:
- reference paths must be in the right places
- imports must be “in order” for runtime JS
You observed:
Lack of reference paths doesn’t always lead to compilation errors, but JS will complain at runtime.
Exactly: TypeScript can still type-check because it “sees” types in the program, but at runtime the browser might load scripts in an order where the namespace objects aren’t initialized yet.
That’s why this approach can feel “dangerous”: you can get a clean compile and still ship broken JS.
ES6 / ES2015 Modules (modern standard)
The modern solution is to use import / export per file.
Key points from your notes:
- In TS, you write imports that behave like they’ll exist at runtime.
- In browser usage, you must load the entry script with
type="module"inindex.html. - If you use ES modules, you should not use
outFile(not compatible in typical modern setups). moduleshould bees2015or above (or modern Node targets likenodenextdepending on environment).
Import paths ending in .js
You wrote:
“import as .js, as though they were already compiled.”
That’s a real-world detail for ESM in the browser and in some Node ESM configurations: the runtime loads .js, even if your source is .ts. Many toolchains handle this for you, but it’s a common gotcha when running ESM directly without bundling.
Module execution frequency
Your question:
“how often does code run? Once per import statement or once per init?”
In ES modules, a module is evaluated once, and then cached; multiple imports reuse the same module instance.
Namespace-style grouped imports in ESM
You noted:
* as namelets you group imports under a namespace-like object.
Example pattern (conceptually): import * as Validation from './validation.js' then use Validation.someFn().
export default
You also noted the default export workflow:
export defaultdefines the main export- importing code can pick any name:
import whateverName from './file.js'
Bundling (why it matters)
You correctly noted why bundling exists:
- Each HTTP request has overhead
- Bundling reduces the number of requests and simplifies deployment
- Webpack is one bundler, but there are others
Also true:
- Bundling tools are common in frontend TS
- In backend TS, bundling is less common (you typically compile to
dist/and run Node, sometimes with TS path aliases, etc.)
Using third-party libraries in TS: @types/* and .d.ts
When libraries don’t ship types
Example: lodash.
- Install JS library:
npm i -s lodash - If it doesn’t include TS types, install community types:
npm i -D @types/lodash
Those @types/* packages contain .d.ts declaration files: type “descriptions” of what the JS library exports, so TS can type-check your usage.
When no types exist
You mentioned:
declare var GLOBAL: any;
That’s an “ambient declaration”: it tells TS “this exists somewhere at runtime, trust me.”
This is useful when:
- a global is injected via a
<script>tag - some runtime environment provides a global value
class-transformer: turning plain JSON into class instances
Problem:
- Data coming from an API/DB as JSON is “plain objects”
- Plain objects don’t have class methods on their prototype
You illustrated that with:
const products = [
{title: 'A carpet', price: 29.99},
{title: 'A book', price: 10.99}
];
const loadedProducts = products.map(prod => {
return new Product(prod.title, prod.price);
})
for(const prod of loadedProducts){
console.log(prod.getInformation());
}//the instantiation allows the calling of this method.Manual instantiation works because you’re actually creating Product instances.
Then class-transformer automates that:
npm i -s class-transformer
npm i -s reflect-metadata # The above command depends on this.import "reflect-metadata"
import { plainToClass } from "class-transformer"
const products = [
{title: 'A carpet', price: 29.99},
{title: 'A book', price: 10.99}
];
const loadedProducts = plainToClass(Product, products)//converts to Product class form.
for(const prod of loadedProducts){
console.log(prod.getInformation());
}
Core idea:
plainToClass(Product, products)converts plain objects into instances ofProduct, so methods likegetInformation()work.
class-validator: validation via decorators
You can attach validation rules directly on class fields.
You noted:
- it uses decorators
- you must enable experimental decorators
Model:
import {IsNotEmpty, IsNumber, IsPositive} from 'class-validator';
export class Product {
@IsNotEmpty()
title: string
@IsNumber
@IsPositive
price: number
constructor(t: string, p: number){
this.title = t;
this.price = p
}
getInformation(){
return [this.title, `$${this.price}`]
}
}Even if object creation “works”, validation is separate:
import 'reflect-metadata'
import { plainToClass } from 'class-transformer';
import {Product} from './product.model'
const newProd = new Product('', -5.99)
console.log(newProd.getInformation)// this still works.
Then you validate explicitly:
import 'reflect-metadata'
import { plainToClass } from 'class-transformer';
import {validate} from 'class-validator'
import {Product} from './product.model'
const newProd = new Product('', -5.99)
validate(newProd).then(errors => {
if(errors.length>0){
console.log('validation errors.')
console.log(errors);
}else {
console.log(newProd.getInformation) // now validation is set.
}
});
Practical takeaway:
- Decorators declare rules, but nothing happens unless you run
validate(...). - This is common in Node/Express/Nest-style backends.
Running TypeScript in Node: compilation vs ts-node
Key constraint:
Node does not execute TS.
So you have two options:
- Compile TS → run JS (recommended for production)
- ts-node (compiles on the fly, convenient for dev, not ideal for prod)
You mentioned:
ts-nodecombines tsc + node execution- use
moduleResolution: node - typical
rootDir: src,outDir: dist - use
nodemonto restart on changes
Express + TypeScript patterns (routes, controllers, models)
Basic Express app
import express from 'express'
const app = express();
app.listen(3000);Dev script example:
"scripts":{
"start": "nodemon dist/app.js"
}Router file
import {Router} from 'express'
const router = Router();
router.post('/');
router.get('/');
router.patch('/:id');
router.delete('/:id');
export default router;
Wiring routes + error middleware
import express, {Request,Response, NextFunction} from 'express'
import todoRoutes from './routes/todos'
const app = express();
app.use('/todos', todoRoutes);
app.use((err:Error,req:Request,res:Response,next:NextFunction) => {//four parameters implies first one is err and middleware is error handling for any previous middlewares.
res.status(500).json({message:err.message})
})
app.listen(3000);Important detail you captured:
- Express treats middleware with 4 params as an error-handling middleware (
(err, req, res, next)).
Controller typing approaches
Option 1: annotate parameters
import {Request, Response, NextFunction} from 'express'
export const createTodo = (req: Request,res: Response,next: NextFunction) => {
};Option 2: RequestHandler type (common + clean)
import {RequestHandler} from 'express'
export const createTodo: RequestHandler = (req,res,next) => {
};Adding body parsing middleware
import express, {Request,Response, NextFunction} from 'express'
import {json} from 'body-parser';
import todoRoutes from './routes/todos'
const app = express();
app.use(json());
app.use('/todos', todoRoutes);
app.use((err:Error,req:Request,res:Response,next:NextFunction) => {//four parameters implies first one is err and middleware is error handling for any previous middlewares.
res.status(500).json({message:err.message})
})
app.listen(3000);Models as runtime values and types
You noted:
every class also acts as a type.
Exactly: classes are emitted to JS (runtime), and also usable as TS types.
Model:
export class Todo{
constructor(public id: string,public text:string){}
}Controller using the model:
import {RequestHandler} from 'express'
import {Todo } from '../models/todo'
const TODOS = [];
export const createTodo: RequestHandler = (req,res,next) => {
const text = req.body.text as string;
const newToDo = new
Todo(Math.random().toString(),text);
TODOS.push(newToDo);
res.status(201).json(message: 'created', createdTodo: newTodo)
};Key ideas in this snippet:
req.bodyis oftenany/unknowndepending on configuration, so you usedas string.- A real app typically validates this input instead of asserting it.
TODOSas[]should ideally be typed (e.g.,Todo[]) so it doesn’t becomeany[].
Connecting routes to controllers
import {Router} from 'express'
import {createTodo} from '../controllers/todos'
const router = Router();
router.post('/',createTodo);
router.get('/');
router.patch('/:id');
router.delete('/:id');
export default router;
Typed route params with generics
For update:
export const updateTodo: RequestHandler<{id:string}> = (req,res,next) => {
const todoId = req.params.id;//generic used.
const updatedText = req.body.text as string;
const todoIndex = TODOS.findIndex(todo => todo.id === todoId);
if(todoIndex <0){
throw new Error('could not find todo!');
}
TODOS[todoIndex] = new Todo(id: TODOS[todoIndex].id, updatedText);
res.json({message: 'updated!', updatedTodo: TODOS[todoIndex]})
}
What this demonstrates:
RequestHandler<{id: string}>tells TS your route params containid.- Then
req.params.idis strongly typed. - Same caveat:
req.body.text as stringis an assertion—validation is better in real systems.
Why this progression matters
- Namespaces + references can compile cleanly but fail at runtime due to ordering/bundling issues.
- ES modules are safer and scale better (the ecosystem expects them).
- Bundling becomes a deployment/performance optimization, not a correctness crutch.
- For backend TS, the typical workflow is compile to
dist, run with Node, and use typed route/controller patterns for maintainability.