25.2 Creating a Program and Type Checker
Right, let’s get our hands dirty. You’ve parsed a file, you’ve looked at its AST, and you feel like a wizard. But a single file is a lonely island. In the real world, TypeScript understands your code by seeing the whole archipelago—every file, every dependency, every declaration. That holistic view is encapsulated in a ts.Program. This isn’t just a fancy concept; it’s the beating heart of the compiler API. It’s the in-memory representation of your entire project, and without it, you’re just playing with syntax trees in a vacuum.
Think of a Program as the compiler’s complete world model. It contains the source files, knows how they relate to each other, and, crucially, holds the key to the holy grail: type information. You can’t get a type checker without first creating a program. It’s the entry fee.
Creating a Program: The createProgram Function
You create this world model using ts.createProgram. It has a few overloads, but the one you’ll use 99% of the time takes two main arguments: an array of root file paths and a compiler options object.
import * as ts from "typescript";
// 1. Define your compiler options. This is a big, often-infuriating object.
// Pro tip: Use the compiler's own logic to get sensible defaults.
const compilerOptions: ts.CompilerOptions = {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.CommonJS,
strict: true,
// ... other options
};
// 2. Specify the entry points to your project. Usually, this is a tsconfig.json's "files" or "include".
// The compiler will automatically resolve and add any imported modules to the program.
const rootFiles = ["./src/main.ts", "./src/helper.ts"];
// 3. Create the program.
// This is where the magic (and the CPU cycles) happens.
const program = ts.createProgram(rootFiles, compilerOptions);
Here’s the first “questionable choice” you’ll encounter: createProgram doesn’t throw errors if you pass it nonsense paths. It just quietly fails to add those files to the program. Your first line of defense is always checking the program’s output.
The Type Checker: Your Gateway to the Truth
Once you have a program, getting the type checker is trivial, but what it unlocks is anything but.
// Get the type checker. This is a lightweight operation; it's already built inside the program.
const typeChecker = program.getTypeChecker();
The type checker is your oracle. It’s the object that answers questions like “what is the type of this variable?”, “does this function call match its signature?”, and “what symbols are visible in this scope?”. It’s the difference between looking at the shape of the code (the AST) and understanding its meaning.
Why You Can’t Just Use One File
Let’s say you have two files:
// math.ts
export const add = (a: number, b: number): number => a + b;
// main.ts
import { add } from './math.js'; // Note the modern .js extension in the import
const result = add(1, "2"); // This is a type error!
If you only parsed main.ts in isolation, you’d see an identifier add and a call expression. But you’d have no idea what add is! Its type is defined in another file. The Program is what connects these two worlds. It loads math.ts, understands the export, and provides that context to the type checker when it looks at main.ts. This is also why that .js extension in the import works—the compiler resolves it based on module resolution rules, another core responsibility of the Program.
Best Practices and Pitfalls
Pitfall #1: The Missing tsconfig.json. You might be tempted to just hardcode compiler options. Don’t. For any real project, read the tsconfig.json file. The compiler provides utilities for this, and it handles all the edge cases (like extending other configs).
// Read the tsconfig.json the way the compiler does it
const configPath = ts.findConfigFile("./", ts.sys.fileExists, "tsconfig.json");
if (!configPath) {
throw new Error("Could not find a valid tsconfig.json");
}
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
const { options, errors } = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
"./" // base path
);
if (errors.length > 0) {
// Always handle these errors! Don't be that dev who ignores them.
console.error(errors);
}
const program = ts.createProgram(rootFiles, options);
Pitfall #2: Assuming All Files Are Loaded. The program object has a getSourceFiles() method. This returns an array of all the SourceFile nodes it knows about. It’s a good sanity check to make sure the files you expected to be included are actually there. If they’re missing, your root files or include/exclude rules are probably wrong.
Best Practice: Lazy Analysis. A common rookie mistake is to create a program and immediately type-check every file in the universe. This is slow and unnecessary. The beauty of the API is that you can be surgical. Create the program once (it’s expensive), get the type checker, and then use it to analyze only the specific nodes you care about as you traverse the AST of your target files. The program and type checker act as a central cache of type information that you query on demand.
The program is your foundation. Everything else—diagnostics, symbol information, refactorings—is built atop this single, crucial object. Get comfortable creating it, and you’ve unlocked the true power of the TypeScript compiler.