TypeScript · lesson 2 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2Setting Up TypeScript
Install TypeScript 7, read what tsc --init generates, run .ts files with Node's type stripping or tsx, and type-check them with tsc --noEmit.
By SkillByExample · Updated
Installing TypeScript
TypeScript should be installed as a project dev dependency rather than globally. This keeps the version pinned to your project, so everyone on the team uses the same compiler. Every command here was run with TypeScript 7.0.2 on Node v24.18.1, in a new folder created with npm init -y.
$ npm install -D typescript
added 2 packages, and audited 3 packages in 1s
found 0 vulnerabilities
$ npx tsc --version
Version 7.0.2
npx tsc runs the compiler from node_modules/.bin. Version 7 is the native compiler written in Go, so the two packages npm added are typescript itself and a platform-specific binary (@typescript/typescript-win32-x64 on this Windows machine). You can install it globally with npm install -g typescript, but then each project uses whatever version that machine happens to have.
Your First Compile
Before adding a tsconfig.json, you can compile a single file directly. Create hello.ts:
// Type annotations are stripped during compilation
const greeting: string = "Hello, TypeScript!";
console.log(greeting);
$ npx tsc hello.ts
$ node hello.js
Hello, TypeScript!
$ node hello.ts
Hello, TypeScript!
tsc printed nothing, which means no errors, and wrote hello.js next to the source. Node 24 can also run hello.ts itself, because Node 22.18 and later strip type annotations before running a file. That is a convenience for running code, not a type check, as Running TypeScript During Development shows.
tsconfig.json
For any real project you need a tsconfig.json. It tells the compiler where your source files live, what JavaScript to emit, and which strictness checks to apply. Generate one:
$ npx tsc --init
Created a new tsconfig.json
You can learn more at https://aka.ms/tsconfig
$ cat tsconfig.json
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
// "rootDir": "./src",
// "outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "nodenext",
"target": "esnext",
"types": [],
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
}
}
tsconfig.json allows comments and trailing commas, so this file is valid as generated. If you learned TypeScript before version 6, several of these values are new. module is nodenext, not CommonJS. target is esnext. And types is an empty list, which means @types/* packages are no longer picked up automatically.
Key tsconfig Options Explained
These options have the biggest effect on your day-to-day experience.
strict: true turns on a bundle of checks that catch real bugs, including:
strictNullChecks—nullandundefinedare not assignable to other types, so you have to handle them explicitlynoImplicitAny— a parameter whose type can’t be inferred must be annotated, which prevents accidentalanystrictFunctionTypes— stricter checking of function parameter types
Since TypeScript 6, strict is also the default when there’s no tsconfig.json at all, which is why tsc rejects an unannotated parameter even for a single file.
target controls which JavaScript syntax tsc rewrites into older syntax. esnext rewrites nothing. TypeScript 7 removed ES5 (it reports error TS5108), so the oldest target left is ES2015.
module: "nodenext" follows Node’s own rules. A file is an ES module or a CommonJS module depending on the nearest package.json’s "type" field, and tsc emits import or require to match. The next section shows why that matters.
types: [] means Node’s built-in names such as process and node:fs aren’t typed until you install @types/node and set "types": ["node"], as the comment in the file says.
verbatimModuleSyntax and isolatedModules require every file to be compilable on its own, without looking at other files. That’s exactly what Node’s type stripping and tools like tsx and esbuild do, so the generated config keeps your code runnable by all of them.
noUncheckedIndexedAccess and exactOptionalPropertyTypes go beyond strict. The first makes arr[0] have type T | undefined. The second stops you from assigning undefined to an optional property that was declared without it.
Compiling a Project
Put the code in src/index.ts:
export function shout(message: string): string {
return message.toUpperCase() + "!";
}
console.log(shout("hello from src/index.ts"));
Then uncomment the two “File Layout” lines in tsconfig.json, so they read "rootDir": "./src", and "outDir": "./dist",, and build. With no file names, tsc reads tsconfig.json:
$ npx tsc
error TS6059: File 'C:/tmp/tssetup/hello.ts' is not under 'rootDir' 'C:/tmp/tssetup/src'. 'rootDir' is expected to contain all source files.
The file is in the program because:
Matched by default include pattern '**/*'
File is CommonJS module because 'C:/tmp/tssetup/package.json' has field "type" whose value is not "module"
Without an include list, tsc compiles every .ts file under the folder, so the old hello.ts is still part of the program, and it sits outside rootDir. Add "include": ["src"] after compilerOptions:
"skipLibCheck": true,
},
"include": ["src"]
}
$ npx tsc
src/index.ts(1,1): error TS1287: A top-level 'export' modifier cannot be used on value declarations in a CommonJS module when 'verbatimModuleSyntax' is enabled.
The last line of the TS6059 error already gave the reason. npm init -y writes "type": "commonjs" into package.json, and with module: nodenext that makes src/index.ts a CommonJS file. Under verbatimModuleSyntax, tsc won’t quietly turn export into exports.shout = .... Declare the package as ES modules:
$ npm pkg set type=module
$ npx tsc
$ ls dist
index.d.ts
index.d.ts.map
index.js
index.js.map
$ node dist/index.js
HELLO FROM SRC/INDEX.TS!
One source file became four output files, because the generated config turns on declaration, declarationMap and sourceMap. Practice 2 looks inside them.
Running TypeScript During Development
Running tsc and then node on every change is slow. There are now two common ways to run a .ts file directly: Node itself, and tsx (npm install -D tsx).
$ node src/index.ts
HELLO FROM SRC/INDEX.TS!
$ npx tsx src/index.ts
HELLO FROM SRC/INDEX.TS!
Both print the same thing, and neither one type-checks (Practice 4 proves it). The difference is what they accept. Node only strips types: it deletes the annotations and runs what’s left. TypeScript syntax that generates JavaScript, such as an enum, can’t be stripped. Save this as src/status.ts:
enum Status {
Active,
Disabled,
}
console.log(Status.Disabled, Status[1]);
$ node src/status.ts
node:internal/modules/run_main:107
triggerUncaughtException(
^
file:///C:/tmp/tssetup/src/status.ts:1
> enum Status {
Active,
Disabled,
> }
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
at parseTypeScript (node:internal/modules/typescript:68:40)
at processTypeScriptCode (node:internal/modules/typescript:146:42)
at stripTypeScriptModuleTypes (node:internal/modules/typescript:209:22)
at ModuleLoader.<anonymous> (node:internal/modules/esm/translators:680:29)
at #translate (node:internal/modules/esm/loader:434:20)
at afterLoad (node:internal/modules/esm/loader:502:29)
at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:507:12)
at #getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:560:36)
at afterResolve (node:internal/modules/esm/loader:607:52)
at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:613:12) {
code: 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'
}
Node.js v24.18.1
$ npx tsx src/status.ts
1 Disabled
tsx compiles each file with esbuild instead of stripping it, so it handles enums, parameter properties and the other non-erasable syntax. Plain node is enough if you avoid that syntax, and tsx covers everything else.
Older guides recommend ts-node. With TypeScript 7 installed, the current release (ts-node 10.9.2) crashes before running anything:
$ npx ts-node src/index.ts
C:\tmp\tssetup\node_modules\ts-node\dist\configuration.js:91
const { fileExists = ts.sys.fileExists, readFile = ts.sys.readFile, skipProject = index_1.DEFAULTS.skipProject, project = index_1.DEFAULTS.project, tsTrace = index_1.DEFAULTS.tsTrace, } = rawApiOptions;
^
TypeError: Cannot read properties of undefined (reading 'fileExists')
at readConfig (C:\tmp\tssetup\node_modules\ts-node\dist\configuration.js:91:33)
at findAndReadConfig (C:\tmp\tssetup\node_modules\ts-node\dist\configuration.js:50:84)
at phase3 (C:\tmp\tssetup\node_modules\ts-node\dist\bin.js:254:67)
at bootstrap (C:\tmp\tssetup\node_modules\ts-node\dist\bin.js:47:30)
at main (C:\tmp\tssetup\node_modules\ts-node\dist\bin.js:33:12)
at Object.<anonymous> (C:\tmp\tssetup\node_modules\ts-node\dist\bin.js:579:5)
at Module._compile (node:internal/modules/cjs/loader:1871:14)
at Object..js (node:internal/modules/cjs/loader:2002:10)
at Module.load (node:internal/modules/cjs/loader:1594:32)
at Module._load (node:internal/modules/cjs/loader:1396:12)
Node.js v24.18.1
ts-node runs the TypeScript compiler through its JavaScript API, and ts.sys doesn’t exist in the TypeScript 7 package, whose lib/ folder holds only a small launcher for the native binary. Remove ts-node and use node or tsx.
A typical set of scripts runs the file directly during development, and keeps tsc for checking and building:
{
"scripts": {
"dev": "node --watch src/index.ts",
"typecheck": "tsc --noEmit",
"build": "tsc",
"start": "node dist/index.js"
}
}
$ npm run build
> tssetup@1.0.0 build
> tsc
$ npm start
> tssetup@1.0.0 start
> node dist/index.js
HELLO FROM SRC/INDEX.TS!
$ npm run dev
> tssetup@1.0.0 dev
> node --watch src/index.ts
HELLO FROM SRC/INDEX.TS!
Completed running 'src/index.ts'. Waiting for file changes before restarting...
node --watch re-runs the file every time you save it, and stays running until you press Ctrl+C. Swap in tsx watch src/index.ts if your code uses enums or other syntax Node can’t strip.
Project Structure
Keeping source and compiled output in separate directories prevents confusion and makes it easy to gitignore the build artifacts. This is the standard layout most TypeScript projects follow:
my-project/
├── src/
│ ├── index.ts
│ ├── utils/
│ │ └── helpers.ts
│ └── types/
│ └── index.ts
├── dist/ # compiled output — add this to .gitignore
├── tsconfig.json
├── package.json
└── .gitignore
Add dist/ to .gitignore — compiled output is derived from source and should never be committed.
VS Code Setup
VS Code has TypeScript support built in, using a copy of TypeScript it bundles. For TypeScript 6 and earlier, the command palette’s TypeScript: Select TypeScript Version → Use Workspace Version points the editor at your project’s node_modules/typescript/lib/tsserver.js, so the editor and npx tsc agree. TypeScript 7 doesn’t ship that file:
$ ls node_modules/typescript/lib
getExePath.d.ts
getExePath.js
tsc.js
version.cjs
version.d.cts
So on TypeScript 7, the editor may report slightly different errors from your project’s tsc. Treat npx tsc --noEmit as the final word.
Recommended extensions that improve the TypeScript experience significantly:
- Error Lens — shows type errors inline in the editor, right next to the code
- Pretty TypeScript Errors — formats complex nested type errors into readable output
Extending a Base Config
The @tsconfig/bases project publishes shareable configs for common environments, so you don’t have to maintain every compiler option yourself. In a new folder with "type": "module" and the same src/index.ts, install the Node 24 base and extend it:
{
"extends": "@tsconfig/node24/tsconfig.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src"]
}
$ npm install -D typescript @tsconfig/node24
$ npx tsc
error TS2688: Cannot find type definition file for 'node'.
The file is in the program because:
Entry point of type library 'node' specified in compilerOptions
tsconfig.json(4,5): error TS5011: The common source directory of 'tsconfig.json' is './src'. The 'rootDir' setting must be explicitly set to this or another path to adjust your output's file layout.
Visit https://aka.ms/ts6 for migration information.
Two errors, both new in TypeScript 6 and later. The base sets "types": ["node"], so it expects @types/node to be installed. And outDir now needs an explicit rootDir. Older versions guessed it from the source files, so src/index.ts would land at dist/index.js or at dist/src/index.js depending on which files existed. Install the types and add "rootDir": "./src" above outDir:
$ npm install -D @types/node@24
$ npx tsc
$ ls dist
index.js
$ node dist/index.js
HELLO FROM SRC/INDEX.TS!
The resolved versions were @tsconfig/node24@24.0.5 and @types/node@24.19.2. The base sets target: es2024 and module: nodenext for you, so your own tsconfig.json only needs the options specific to this project.
Running TypeScript 6 Alongside
Some tools still need TypeScript 6, and the TypeScript team publishes @typescript/typescript6, which provides a tsc6 command. Installed in the same project as TypeScript 7, it takes over tsc too:
$ npm install -D typescript @typescript/typescript6
$ npm ls --depth=0
tsboth@1.0.0 C:\tmp\tsboth
├── @typescript/typescript6@6.0.2
└── typescript@7.0.2
$ npx tsc --version
Version 6.0.3
$ npx tsc6 --version
Version 6.0.3
@typescript/typescript6 depends on a renamed copy of typescript@6, and npm linked that copy’s tsc into node_modules/.bin, overwriting TypeScript 7’s. Uninstalling it removes the link entirely, and npx tsc then downloads an unrelated package named tsc. To run TypeScript 6 once, without touching the project, ask npx for it:
$ npx tsc --version
Version 7.0.2
$ npx -p typescript@6 tsc --version
Version 6.0.3
Running the Build
These are the three compiler commands you will use day to day. --noEmit checks types without writing anything, and prints nothing when there are no errors:
$ npx tsc --noEmit; echo "exit code $?"
exit code 0
The exit code is what CI looks at: 0 with no errors, and non-zero as soon as there is one. npx tsc with no options does a full build into dist/, as above. --watch keeps running and rechecks on every save. Here a line was appended to src/index.ts while it ran (--preserveWatchOutput stops it clearing the screen between runs):
$ npx tsc --watch --preserveWatchOutput
10:39:12 PM - Starting compilation in watch mode...
10:39:12 PM - Found 0 errors. Watching for file changes.
10:39:15 PM - File change detected. Starting incremental compilation...
src/index.ts(6,7): error TS2322: Type 'string' is not assignable to type 'number'.
10:39:15 PM - Found 1 error. Watching for file changes.
The appended line was const count: number = "three";, and watch mode reported it within moments of the save. Leave this running in a terminal next to npm run dev, so one shows output and the other shows type errors.
Practice
The solutions were run with TypeScript 7.0.2 and tsx 4.23.15 on Node v24.18.1, in the project
built up in this lesson (tsc --init config with rootDir, outDir and "include": ["src"], and
"type": "module" in package.json).
- Add a line to
hello.tsthat breaks its own annotation:const count: number = "three";, and change theconsole.logto printgreeting, count. Compile it withnpx tsc hello.ts. The project now has atsconfig.json, so read the error, follow its advice, then runnode hello.js.
Solution
// Type annotations are stripped during compilation
const greeting: string = "Hello, TypeScript!";
const count: number = "three";
console.log(greeting, count);
$ npx tsc hello.ts
error TS5112: tsconfig.json is present but will not be loaded if files are specified on commandline. Use '--ignoreConfig' to skip this error.
$ ls hello.js
ls: cannot access 'hello.js': No such file or directory
$ npx tsc --ignoreConfig hello.ts
hello.ts(3,7): error TS2322: Type 'string' is not assignable to type 'number'.
$ node hello.js
Hello, TypeScript! three
Since TypeScript 6, naming a file in a folder that has a tsconfig.json is an error, and nothing
is written. --ignoreConfig compiles the file with the default options instead. The type check
fails, but tsc still emits JavaScript, because type errors don’t block output by default. Once
the annotation is stripped, nothing stops Node from running the string.
- Build the project with
npx tscand look at what it wrote. Which files come fromdeclaration,declarationMapandsourceMap, and what is in the.d.tsfile?
Solution
$ npx tsc
$ ls dist
index.d.ts
index.d.ts.map
index.js
index.js.map
$ node dist/index.js
HELLO FROM SRC/INDEX.TS!
$ cat dist/index.d.ts
export declare function shout(message: string): string;
//# sourceMappingURL=index.d.ts.map
declaration writes index.d.ts, which holds the types without the function body. That’s what
another TypeScript project reads when it imports this one. declarationMap writes
index.d.ts.map, so “go to definition” in an editor lands in src/index.ts, not the .d.ts.
sourceMap writes index.js.map, so stack traces and debuggers point at the TypeScript source.
- Remove the annotations from
shoutso it readsexport function shout(message) {, deletedist/, and runnpx tsc --noEmit. Then try turning the check off by changing"strict": trueto"strict": false. If that doesn’t work, putstrictback and pass--noImplicitAny falseon the command line instead.
Solution
export function shout(message) {
return message.toUpperCase() + "!";
}
console.log(shout("hello from src/index.ts"));
With "strict": true:
$ npx tsc --noEmit
src/index.ts(1,23): error TS7006: Parameter 'message' implicitly has an 'any' type.
$ ls dist
ls: cannot access 'dist': No such file or directory
With "strict": false:
$ npx tsc --noEmit && echo "no type errors"
tsconfig.json(25,5): error TS5052: Option 'exactOptionalPropertyTypes' cannot be specified without specifying option 'strictNullChecks'.
With "strict": true and --noImplicitAny false:
$ npx tsc --noEmit --noImplicitAny false && echo "no type errors"
no type errors
$ ls dist
ls: cannot access 'dist': No such file or directory
noImplicitAny, one of the flags strict turns on, refuses a parameter whose type it can’t
infer. Turning all of strict off fails in this config, because the generated
exactOptionalPropertyTypes only makes sense with strictNullChecks. Turning off just
noImplicitAny lets message silently become any, and the check passes. In every case,
--noEmit writes nothing to dist/.
- Keep the unannotated
shoutfrom task 3, withstricton. Runsrc/index.tswithnodeand withtsx, then runnpx tsc --noEmit. Which of the three notices the problem?
Solution
$ node src/index.ts
HELLO FROM SRC/INDEX.TS!
$ npx tsx src/index.ts
HELLO FROM SRC/INDEX.TS!
$ npx tsc --noEmit
src/index.ts(1,23): error TS7006: Parameter 'message' implicitly has an 'any' type.
Only tsc. node and tsx both remove the types and run the JavaScript, without checking
anything, so code that fails the strict check runs fine. That’s why the typecheck script
exists: run it in CI, in a pre-push hook, or alongside npm run dev in a second terminal.