Skip to Content
BlogCodeUnderstanding TypeScriptunderstanding typescript 3

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.
  • export exposes 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 outFile in tsconfig (e.g. ./dist/bundle.js)
  • switch module to amd
  • this produces a single bundle.js instead 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" in index.html.
  • If you use ES modules, you should not use outFile (not compatible in typical modern setups).
  • module should be es2015 or above (or modern Node targets like nodenext depending 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 name lets 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 default defines 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 of Product, so methods like getInformation() 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-node combines tsc + node execution
  • use moduleResolution: node
  • typical rootDir: src, outDir: dist
  • use nodemon to 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.body is often any/unknown depending on configuration, so you used as string.
  • A real app typically validates this input instead of asserting it.
  • TODOS as [] should ideally be typed (e.g., Todo[]) so it doesn’t become any[].

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 contain id.
  • Then req.params.id is strongly typed.
  • Same caveat: req.body.text as string is 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.