- Book: PHP to TypeScript
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A Laravel developer on their first TypeScript project opens the repo and sees a familiar shape. There is a manifest at the root. There is a lockfile next to it. There is a giant folder of third-party code: node_modules/ instead of vendor/. The instinct is to treat them as the same thing with different names.
So they go looking for a class. They want to find where pino's Logger lives, the way they would find Illuminate\Support\Collection in vendor/. They open node_modules/pino/, expect a directory tree shaped like a namespace, and find a package.json, a lib/ folder, a pino.js, several .d.ts files, and an exports map they have to read carefully to figure out what is even importable.
That moment of friction is the entire post. PHP's autoloader and npm's resolver look like cousins. They are not. They were designed to answer different questions, and once a developer sees that, the rest of the TypeScript module experience stops feeling random.
What Composer is doing under the hood
PSR-4 is the rule that ties a namespace to a directory. The composer.json file declares a prefix; Composer generates a static map of that prefix to a directory; the autoloader takes a fully qualified class name, splits it on \, and reads the corresponding file off disk.
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Given that block, App\Services\UserService is src/Services/UserService.php. There is no other place it can live. The namespace mirrors the directory, and the terminating class name is the file name (that's the actual PSR-4 mandate; the "one class per file" convention is a separate PSR-1 SHOULD).
When code says new App\Services\UserService(), the autoloader runs spl_autoload_call, walks the registered PSR-4 prefixes, finds App\ mapped to src/, computes the path, requires the file, and the class shows up in memory. The transformation is mechanical. You can predict the file location of any class without running anything; a grep on the namespace finds the file every time.
This applies to dependencies too. Symfony\Component\HttpFoundation\Request lives at vendor/symfony/http-foundation/Request.php. Composer ran the same algorithm against the package's own composer.json. The shape of the dependency tree is determined by namespaces, and the file system mirrors that shape exactly.
The conceptual model in one line: a class is a file, and the namespace is the address.
This is why PHP developers learn to navigate vendor/ by hand. The directory layout is a contract. Open a Symfony component, find the class you want, read it. The package author cannot hide a class from you, cannot rename the path, cannot give you "the public part" while keeping the rest private. PSR-4 is symmetrical: anything the autoloader can find, you can find.
What npm is doing under the hood
npm has nothing to say about classes or namespaces. npm publishes packages. A package is a tarball with a package.json at its root. When you install it, the tarball gets extracted into node_modules/<package-name>/, and the contents are whatever the author put in.
package.json carries a small set of fields that tell Node and bundlers what to do when someone writes import x from "<package-name>":
-
main— the legacy CommonJS entry point. A relative path inside the package. -
module— the legacy ESM entry point that some bundlers honored. -
types(ortypings) — the entry point for TypeScript declarations. -
exports— the modern, conditional entry-point map. Supersedes the rest.
A real example from a well-published library:
{
"name": "pino",
"main": "pino.js",
"type": "commonjs",
"exports": {
".": {
"types": "./pino.d.ts",
"import": "./pino.js",
"require": "./pino.js",
"default": "./pino.js"
},
"./file": "./file.js",
"./pretty": "./pretty.js",
"./package.json": "./package.json"
}
}
Reading top to bottom: when you write import pino from "pino", the resolver picks the entry under ".". When you write import file from "pino/file", it picks "./file". The names on the left of the map are not file paths. They are public identifiers chosen by the author. The actual files are on the right.
Importing pino/lib/internal-thing.js throws a resolution error in modern Node — ERR_PACKAGE_PATH_NOT_EXPORTED. Once a package ships an exports map, anything not listed is private. The author decides exactly which subpaths are reachable. Node's docs on subpath exports spell out the resolution rules.
The conceptual model in one line: a package is a manifest, and the manifest decides what is reachable.
There is no PSR-4-style mechanical map from name to path. Two packages that look identical on disk can expose entirely different surfaces. The shape of the import is whatever the author wrote in the exports field.
Conditional exports: the thing PHP never had
The exports field gets more interesting when you read its second job. It is conditional. The same import can resolve to different files depending on who is asking.
{
"name": "@acme/sdk",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.cjs"
},
"./node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.mjs",
"require": "./dist/node.cjs"
},
"./browser": {
"types": "./dist/browser.d.ts",
"import": "./dist/browser.mjs",
"require": "./dist/browser.cjs"
}
}
}
Three things are happening:
-
typesfirst. TypeScript's resolver, inmoduleResolution: "bundler"or"node16", pickstypesto find the declaration file. This is how a single import gets both a runtime module and its types. -
importvsrequire. ESM consumers get.mjs, CommonJS consumers get.cjs. The author shipped both bundles; the resolver picks based on the call site. -
Subpath conditions.
./nodeand./browserexist as different surfaces of the same package. A bundler targeting the browser refuses to resolve./node; Node refuses to load./browseras a server module. Different runtimes get different code.
Composer has nothing remotely like this. PHP runs PHP, period. There is no "here is the browser version of this class" because the language only ships one runtime. npm packages live in a world where the same identifier needs to resolve to different files for Node, the browser, Bun, Deno, server-side React, edge workers, and TypeScript at design time. exports is what makes one published package work in all of them.
The flip side is that you cannot read a package's surface from its file tree. You have to read its package.json. The PHP habit of "open the directory and look around" produces wrong answers in npm-land.
The bare-import rule
There is one more thing PSR-4 does not have: the bare-import distinction.
In TypeScript, this:
import { Pool } from "pg";
is a bare import. The resolver walks up the directory tree looking for a node_modules/pg/, then reads its package.json, then resolves the exports map.
This:
import { resolveTenant } from "./services/tenant";
is a relative import. The resolver walks the file system from the current file. No node_modules, no package.json, no exports map. It is plain path resolution: ./services/tenant.ts, ./services/tenant.tsx, ./services/tenant/index.ts, with extension and index lookup rules from the active moduleResolution setting.
The two paths through the resolver share almost no logic. Bare imports go through package metadata. Relative imports go through file paths.
A PHP developer importing App\Services\UserService does not feel this difference because there is no difference. Whether the class lives in their app or in a Composer dependency, the autoloader resolves it the same way: namespace-to-path. In TypeScript the two cases are different machinery.
The practical consequence: a bug in your relative imports usually means a typo or a path mistake. A bug in your bare imports usually means a package.json mismatch — the package's exports does not list the path you tried, or your moduleResolution is set to a value that ignores exports. The fix lives in different places.
The dist/ versus src/ thing
PHP packages ship source. The file you require is the file the author wrote. There is no build step between the package author's editor and your vendor/ directory.
npm packages mostly do not. Open node_modules/<anything>/ and you usually find a dist/ (or lib/, or build/) folder full of compiled JavaScript and .d.ts declarations. The TypeScript source the author wrote is not there. It lives in their git repository, not in the published tarball.
The exports map points at dist/. Your imports resolve to compiled artifacts. When a developer says "I am stepping into the source of this library to read its code," they mean reading the bundled output. To get the actual source, they clone the package's git repo or open it on GitHub.
This trips you up because it inverts an assumption. In vendor/symfony/http-foundation/, you read the same code the maintainer wrote. In node_modules/pino/, you read minified, transpiled, possibly bundled code that has had its types written separately as .d.ts files. In npm, the file that ships is not the file the author wrote. In Composer, it is.
A useful adjustment: when reading a TypeScript dependency to understand its behaviour, do not start in node_modules/. Open its repository. The published artifact is for runtime; the repository is for reading.
Where the model bites in practice
A PHP developer rewiring their mental model usually walks into four predictable problems.
Barrel imports surprise nobody, then surprise everybody. A package exports everything from a single root entry — the exports map has one entry, ., and that entry pulls in the whole library through an internal index.ts of re-exports. Importing one helper drags the import graph of the entire library into the bundle. The fix is subpath imports (pino/file) or libraries that publish multiple subpath entries. PHP autoloading is lazy by default; npm imports often are not.
Deep imports get blocked. Five years ago, importing lodash/fp/map worked because node_modules/lodash/fp/map.js existed. Today, libraries with strict exports blocks anything that is not in the map. Code that worked on an old version stops working on a new version. The error message is "Package subpath './fp/map' is not defined by 'exports'", and the fix is to use whatever the maintainer has decided to expose. The exports map is a public API boundary, and respecting it is a one-way door.
moduleResolution decides what is even legal. TypeScript supports several resolvers — node, node16, nodenext, bundler. They differ in whether exports is honored, whether file extensions are required in relative imports, and how types conditions are handled. Set the wrong one and a perfectly valid package looks broken. Pick node and exports is silently ignored, so a package that ships only through its exports map looks unreachable. The TypeScript handbook's Module Resolution page spells out which resolver honors exports and which ignores it.
Dual ESM/CJS packages have shape, not size. A dependency that publishes both an ESM and a CJS entry point can be imported from either world, but only the surface the exports map allows. A CJS consumer that reaches into the package's ESM bundle directly will fail. PHP has nothing like this; in TypeScript it is part of every other dependency.
The mental rewiring
The translation table that gets a PHP developer to a productive TypeScript module mindset is short.
| PHP question | TypeScript replacement |
|---|---|
"Where is the file for App\Services\UserService?" |
"What does the package export from this name?" |
"How does Composer find vendor/symfony/http-foundation/Request.php?" |
"How does the resolver pick which file import { Request } from 'pkg' lands on?" |
| "Will my namespace map to a directory?" | "Does my tsconfig.json paths map this alias to a folder?" |
| "Is this class private?" | "Is this subpath in the exports map?" |
The two systems answer different questions because they grew up in different environments. Composer's job was to give a single language a deterministic, namespace-driven autoloader. PSR-4 is what that looks like when the answer is allowed to be mechanical. npm's job was to give a sprawling polyglot ecosystem one shared way to publish code: Node, browsers, bundlers, and runtimes that did not exist when the format was designed. The exports map is what that looks like when the answer has to be flexible.
You do not need to like one model more than the other to use both. You do need to stop importing the PHP question. "Where is this class?" is not a meaningful question in npm. The replacement is "what does the package export, and which condition picks the file I will actually load?"
Once that swap is made, node_modules/ stops feeling like a worse vendor/ and starts feeling like what it is: a flat cache of tarballs whose contents are described by their manifests, not by their file tree.
If this was useful
PHP to TypeScript is the bridge book in The TypeScript Library — written for PHP 8+ developers who already ship and want the mental-model translation, not a "rewrite your Laravel app" tour. Namespaces to modules, autoload to resolver, dynamic types to discriminated unions, sync to async.
| # | Book | Best fit |
|---|---|---|
| 1 | TypeScript Essentials | Entry point. Types, narrowing, modules, async, daily-driver tooling |
| 2 | The TypeScript Type System | Deep dive on generics, mapped/conditional types, branded types |
| 3 | Kotlin and Java to TypeScript | Bridge for JVM developers — variance, null safety, sealed types |
| 4 | PHP to TypeScript | Bridge for PHP 8+ developers — sync→async, generics, unions |
| 5 | TypeScript in Production | tsconfig, build tools, monorepos, dual ESM/CJS, library authoring |
Migrating from PHP: books 4 and 5 fit best — the bridge plus the production layer. For the canonical core path, books 1 and 2 substitute for 4.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)