Why we rebuild TypeScript foundations from the ground up
Most engineers learn TypeScript backwards: inherit a codebase, copy an interface pattern without fully understanding it, and move on the moment the red squiggly line disappears. That works until it doesn't — until an engineer is staring at a generic constraint error or a mapped type that refuses to narrow, with no mental model to reach for, on a client's production branch.
So as part of our internal growth program, we ask engineers to do the opposite. One of our team members built a dedicated repo, numbered it like a syllabus, and worked through the type system deliberately: basics, the compiler, classes and interfaces, advanced types, generics, type derivation (keyof, typeof, infer, conditional and mapped types), decorators, modules, and then the tooling layer — Vite, Webpack, and a React integration — on top. Each folder is a self-contained lesson with its own tsconfig.json, so compiler behavior could be isolated without one module's config leaking into another's.
This isn't a demo app pretending to be a "project." It's closer to a lab notebook — and that's the point of running it as a company program rather than leaving it to chance. Clients and teams don't need proof that we can wire up a CRUD app; there are a hundred of those in our portfolio already. What they need is confidence that when TypeScript's type system gets adversarial — discriminated unions, conditional types, infer — the engineer on their project isn't guessing.
The type system, not just the syntax
The early folders (02. Basics through 07. Advanced Types) cover the vocabulary everyone knows on paper — unions, tuples, enums, interfaces — but our engineers push into the parts that actually separate "writes TypeScript" from "thinks in TypeScript": discriminated unions and type guards.
type FileSource = { type: 'file'; path: string };
type DBSource = { type: 'db'; connectionUrl: string };
type Source = FileSource | DBSource;
function loadData(source: Source) {
if (source.type === 'file') {
source.path; // narrowed to FileSource
return;
}
source.connectionUrl; // narrowed to DBSource
}
The type tag is what makes this exhaustive and safe — no as casts, no any escape hatches. The curriculum also works through the instanceof-based guard pattern for class hierarchies (User vs Admin), because discriminated unions only work when you control the shape; runtime class checks are the fallback when you don't.
Deriving types instead of writing them twice
The 10. Deriving Types folder is where the curriculum earns its keep. This is the part of TypeScript most engineers avoid — conditional types, infer, mapped types, template literal types — because the payoff isn't obvious until you've been burned by keeping two type definitions in sync by hand on a real project.
Conditional types with infer, used to extract an array's element type generically:
type GetElementType<T> = T extends any[] ? T[number] : never;
type Example1 = GetElementType<string[]>; // string
type Example2 = GetElementType<string>; // never
Mapped types, used to derive a "results" shape from an existing operations object without redeclaring it:
type Operations = {
add: (a: number, b: number) => number;
subtract: (a: number, b: number) => number;
};
type Results<T> = {
readonly [Key in keyof T]?: number;
};
let mathResults: Results<Operations> = {
add: mathOperations.add(1, 2),
subtract: mathOperations.subtract(5, 2),
};
And template literal types, combined with mapped types to generate event-name unions from a data shape:
type ReadPermissions = 'no-read' | 'read';
type WritePermissions = 'no-write' | 'write';
type FilePermissions = `${ReadPermissions}-${WritePermissions}`;
type DataFileEventNames = `${keyof DataFile}Changed`;
type DataFileEvents = {
[Key in DataFileEventNames]: () => void;
};
This is the pattern our engineers now reach for whenever they see two hand-written types that are really the same information expressed twice — a request DTO and its "changed" event map, a config object and its partial/readonly variants. Derive one from the other and the compiler keeps them honest, which is exactly the kind of habit we want carried into client codebases.
Generics as a design tool, not decoration
The 09. Demo and 08. Generic Types folders work through generics on a real data structure — a singly linked list — rather than toy identity<T>(x: T) examples that don't reveal anything about variance or constraint design.
class ListNode<T> {
next?: ListNode<T>;
constructor(public value: T) {}
}
class LinkedList<T> {
private root?: ListNode<T>;
private tail?: ListNode<T>;
private length = 0;
add(value: T) {
const node = new ListNode(value);
if (!this.root || !this.tail) {
this.root = node;
this.tail = node;
} else {
this.tail.next = node;
this.tail = node;
}
this.length++;
}
// insertAt, removeAt, print, getNumberOfElements...
}
Implementing insertAt and removeAt with generics forces an engineer to confront the actual reason generics exist: LinkedList<number> and LinkedList<string> should share one implementation with zero any, and the compiler should still catch a numberList.add('oops') at the call site. Toy examples don't test that; a real structure with pointer manipulation does.
Tooling: three ways to compile the same TypeScript
The back half of the curriculum (14–19) is deliberately about the build pipeline, because "knows TypeScript" is incomplete without "knows how TypeScript gets from source to something a browser runs." We have engineers set up the same kind of project three different ways:
| Approach | Folder | What it's for |
|---|---|---|
| Native ES modules | 14. Modules | No bundler — browser-native import/export, understanding what the platform gives you for free |
| Vite | 15. Vite | Fast dev server, ESM-native dev experience, minimal config |
| Webpack | 16. Webpack | Full manual control over loaders, dev server, and source maps |
The Webpack config is intentionally explicit rather than relying on presets:
module.exports = {
mode: 'development',
entry: './src/app.ts',
devServer: { static: [{ directory: path.join(__dirname) }] },
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/dist/',
},
devtool: 'inline-source-map',
module: {
rules: [{ test: /\.ts$/, use: 'ts-loader', exclude: /node_modules/ }],
},
resolve: { extensions: ['.ts', '.js'] },
};
Writing this by hand instead of using create-react-app or a Vite template means an engineer knows exactly what ts-loader is doing, why resolve.extensions matters for extensionless imports, and what breaks if devtool isn't set correctly for production. Folder 19. React then layers a Vite + React + TypeScript setup on top, so the same generic and narrowing patterns from earlier folders carry into component props and event handlers instead of staying academic.
What this program demonstrates
A curriculum like this isn't meant to impress with scale — it's meant to build depth we can put behind client work. When we take on frontend or full-stack engagements, the value isn't that an engineer can import a UI library; it's that when a generic constraint fights back, or a third-party type definition is wrong, or a build needs a custom Webpack loader instead of a template's defaults, that engineer has already done the work of understanding why, not just how. That's the difference between code that passes review and code that holds up six months later when someone else on the team has to extend it.
