JavaScript — TypeScript Integration
TypeScript is a typed superset of JavaScript that compiles to plain JavaScript. It adds static type checking, interfaces, generics, and advanced tooling to the JavaScript ecosystem. TypeScript does not change how JavaScript runs — it catches type-related errors at compile time rather than runtime, shifting bug discovery earlier in the development cycle.
The relationship between JavaScript and TypeScript is pragmatic: any valid JavaScript is valid TypeScript (with some caveats). TypeScript infers types where possible, and you can opt in to stricter checking gradually. This makes adoption incremental — you can add TypeScript to a JavaScript project file by file, or use JSDoc annotations for type checking without changing file extensions.
TypeScript's value proposition extends beyond type safety: it enables better IDE support (autocomplete, refactoring, navigation), serves as living documentation for APIs, and makes codebases more maintainable at scale. This guide covers the practical integration of TypeScript into JavaScript projects, from basic annotations to advanced patterns.
| 1 | // Basic TypeScript — type annotations |
| 2 | function greet(name: string): string { |
| 3 | return `Hello, ${name}!`; |
| 4 | } |
| 5 | |
| 6 | // TypeScript infers the return type |
| 7 | const message = greet('World'); // inferred as string |
| 8 | |
| 9 | // TypeScript catches errors at compile time |
| 10 | // greet(42); // Error: Argument of type 'number' is not assignable to parameter of type 'string' |
| 11 | |
| 12 | // Interfaces define the shape of objects |
| 13 | interface User { |
| 14 | id: number; |
| 15 | name: string; |
| 16 | email: string; |
| 17 | role?: 'admin' | 'user'; // optional with union type |
| 18 | } |
| 19 | |
| 20 | function createUser(data: User): User { |
| 21 | return { |
| 22 | id: data.id, |
| 23 | name: data.name, |
| 24 | email: data.email, |
| 25 | role: data.role ?? 'user', |
| 26 | }; |
| 27 | } |
| 28 | |
| 29 | // Generics — reusable type-safe components |
| 30 | function identity<T>(value: T): T { |
| 31 | return value; |
| 32 | } |
| 33 | |
| 34 | const num = identity<number>(42); |
| 35 | const str = identity('hello'); // type inferred as string |
Type annotations are the core of TypeScript. They declare the expected types of variables, function parameters, return values, and properties. TypeScript's type system is structural (not nominal) — compatibility is determined by the shape of types, not their declared names. The compiler uses type inference extensively, so annotations are only required where the type cannot be automatically determined.
| Type | Example | Description |
|---|---|---|
| string | let name: string | Text values |
| number | let age: number | All numeric values (int, float, NaN) |
| boolean | let isActive: boolean | true / false |
| T[] | let items: number[] | Array of a given type |
| [T, U] | let pair: [string, number] | Tuple of fixed length |
| T | U | let id: string | number | Union type (either/or) |
| T & U | let obj: A & B | Intersection type (both) |
| T? | let name?: string | Optional (can be undefined) |
| T | null | let data: T | null | Nullable type |
| unknown | let val: unknown | Type-safe any (must narrow before use) |
| 1 | // Type inference — TypeScript figures out types automatically |
| 2 | let name = 'Alice'; // inferred as string |
| 3 | let count = 42; // inferred as number |
| 4 | let isReady = true; // inferred as boolean |
| 5 | |
| 6 | // Explicit annotations — when inference isn't enough |
| 7 | let data: string | null = null; |
| 8 | let ids: number[] = []; |
| 9 | let pair: [string, number] = ['age', 30]; |
| 10 | let callback: (x: number) => void; |
| 11 | |
| 12 | // Union types — value can be one of several types |
| 13 | function formatId(id: string | number): string { |
| 14 | return `ID: ${id}`; |
| 15 | } |
| 16 | |
| 17 | // Type narrowing — refine union types with control flow |
| 18 | function process(value: string | number | null) { |
| 19 | if (value === null) { |
| 20 | console.log('No value provided'); |
| 21 | } else if (typeof value === 'string') { |
| 22 | console.log(`String: ${value.toUpperCase()}`); |
| 23 | } else { |
| 24 | console.log(`Number: ${value.toFixed(2)}`); |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | // Literal types — exact values as types |
| 29 | type Direction = 'up' | 'down' | 'left' | 'right'; |
| 30 | function move(dir: Direction) { |
| 31 | console.log(`Moving ${dir}`); |
| 32 | } |
| 33 | move('up'); // OK |
| 34 | // move('diagonal'); // Error! |
| 35 | |
| 36 | // Type assertions — when you know more than the compiler |
| 37 | const element = document.getElementById('app') as HTMLDivElement; |
| 38 | // or: const element = <HTMLDivElement>document.getElementById('app'); |
| 39 | |
| 40 | // Non-null assertion |
| 41 | const input = document.querySelector('input')!; |
| 42 | // ! tells TS: "I know this is not null" |
Interfaces and type aliases are the primary ways to define object shapes in TypeScript. Interfaces are extensible (declaration merging, extends), while type aliases can represent unions, intersections, primitives, and tuples. Choose interfaces for public API contracts and object definitions; use types for unions, computed properties, and complex compositions.
| 1 | // Interfaces — preferred for object shapes |
| 2 | interface Person { |
| 3 | name: string; |
| 4 | age: number; |
| 5 | email?: string; // optional |
| 6 | readonly id: number; // cannot be modified after creation |
| 7 | } |
| 8 | |
| 9 | // Interface extension |
| 10 | interface Employee extends Person { |
| 11 | department: string; |
| 12 | salary: number; |
| 13 | } |
| 14 | |
| 15 | // Declaration merging — interfaces can be augmented |
| 16 | interface Person { |
| 17 | phone?: string; |
| 18 | } |
| 19 | // Person now has: name, age, email?, id, phone? |
| 20 | |
| 21 | // Type aliases — more flexible |
| 22 | type Point = { |
| 23 | x: number; |
| 24 | y: number; |
| 25 | }; |
| 26 | |
| 27 | // Types can represent unions |
| 28 | type Status = 'active' | 'inactive' | 'pending'; |
| 29 | type Result<T> = { success: true; data: T } | { success: false; error: string }; |
| 30 | |
| 31 | // Types can use computed properties |
| 32 | const key = 'role' as const; |
| 33 | type Role = { [key]: string }; |
| 34 | |
| 35 | // Types for function signatures |
| 36 | type MathFn = (a: number, b: number) => number; |
| 37 | const add: MathFn = (a, b) => a + b; |
| 38 | |
| 39 | // Generic interface |
| 40 | interface Repository<T> { |
| 41 | get(id: string): Promise<T | null>; |
| 42 | getAll(): Promise<T[]>; |
| 43 | create(item: T): Promise<T>; |
| 44 | update(id: string, item: Partial<T>): Promise<T>; |
| 45 | delete(id: string): Promise<boolean>; |
| 46 | } |
| 47 | |
| 48 | // Index signature — dynamic keys |
| 49 | interface Dictionary<T> { |
| 50 | [key: string]: T | undefined; |
| 51 | } |
| 52 | |
| 53 | const cache: Dictionary<number> = {}; |
| 54 | cache['visits'] = 42; |
| 55 | |
| 56 | // extends vs intersection |
| 57 | interface Animal { name: string } |
| 58 | interface Bear extends Animal { honey: boolean } |
| 59 | // vs |
| 60 | type BearType = Animal & { honey: boolean }; |
| 61 | // Both work for objects, but interfaces are more performant and debuggable |
best practice
Generics create reusable components that work with multiple types while maintaining type safety. Instead of specifying a concrete type, you use a type parameter (commonly T, U, V) that is resolved when the component is used. Generics are the backbone of TypeScript's standard library — Array<T>, Promise<T>, Map<K, V> are all generic.
| 1 | // Basic generic function |
| 2 | function firstElement<T>(arr: T[]): T | undefined { |
| 3 | return arr[0]; |
| 4 | } |
| 5 | |
| 6 | const first = firstElement([1, 2, 3]); // type: number | undefined |
| 7 | const str = firstElement(['a', 'b']); // type: string | undefined |
| 8 | |
| 9 | // Generic constraints — limit what types are accepted |
| 10 | interface HasLength { |
| 11 | length: number; |
| 12 | } |
| 13 | |
| 14 | function logLength<T extends HasLength>(item: T): T { |
| 15 | console.log(item.length); |
| 16 | return item; |
| 17 | } |
| 18 | |
| 19 | logLength('hello'); // OK (string has length) |
| 20 | logLength([1, 2, 3]); // OK (array has length) |
| 21 | // logLength(42); // Error: number doesn't have length |
| 22 | |
| 23 | // Multiple type parameters |
| 24 | function pair<T, U>(first: T, second: U): [T, U] { |
| 25 | return [first, second]; |
| 26 | } |
| 27 | |
| 28 | const result = pair('key', 42); // type: [string, number] |
| 29 | |
| 30 | // Generic class |
| 31 | class Stack<T> { |
| 32 | private items: T[] = []; |
| 33 | |
| 34 | push(item: T): void { |
| 35 | this.items.push(item); |
| 36 | } |
| 37 | |
| 38 | pop(): T | undefined { |
| 39 | return this.items.pop(); |
| 40 | } |
| 41 | |
| 42 | peek(): T | undefined { |
| 43 | return this.items[this.items.length - 1]; |
| 44 | } |
| 45 | |
| 46 | get size(): number { |
| 47 | return this.items.length; |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | const numberStack = new Stack<number>(); |
| 52 | numberStack.push(1); |
| 53 | numberStack.push(2); |
| 54 | console.log(numberStack.pop()); // 2 |
| 55 | |
| 56 | // Generic constraint with keyof |
| 57 | function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { |
| 58 | return obj[key]; |
| 59 | } |
| 60 | |
| 61 | const user = { name: 'Alice', age: 30, role: 'admin' }; |
| 62 | getProperty(user, 'name'); // type: string |
| 63 | getProperty(user, 'age'); // type: number |
| 64 | // getProperty(user, 'invalid'); // Error! |
| 65 | |
| 66 | // Generic mapped type |
| 67 | type Readonly_<T> = { |
| 68 | readonly [P in keyof T]: T[P]; |
| 69 | }; |
| 70 | |
| 71 | type Optional<T> = { |
| 72 | [P in keyof T]?: T[P]; |
| 73 | }; |
info
TypeScript provides a set of built-in utility types that transform existing types. These enable common type manipulations without writing complex generic logic. Utility types are the TypeScript equivalent of lodash for types — they compose and derive new types from existing ones, reducing boilerplate and improving consistency.
| Utility | Description | Example |
|---|---|---|
| Partial<T> | Makes all properties optional | Partial<User> |
| Required<T> | Makes all properties required | Required<Config> |
| Readonly<T> | Makes all properties readonly | Readonly<State> |
| Pick<T, K> | Selects specific properties | Pick<User, 'id' | 'name'> |
| Omit<T, K> | Removes specific properties | Omit<User, 'password'> |
| Record<K, T> | Creates object type with keys K and values T | Record<string, number> |
| Exclude<T, U> | Excludes types from union | Exclude<'a'|'b', 'a'> |
| ReturnType<T> | Extracts return type of function type | ReturnType<typeof fn> |
| Awaited<T> | Unwraps promise type | Awaited<Promise<string>> |
| 1 | // Partial — useful for updates |
| 2 | interface User { |
| 3 | id: number; |
| 4 | name: string; |
| 5 | email: string; |
| 6 | role: string; |
| 7 | } |
| 8 | |
| 9 | function updateUser(id: number, changes: Partial<User>): void { |
| 10 | // changes can have any subset of User properties |
| 11 | } |
| 12 | |
| 13 | updateUser(1, { name: 'Alice' }); // OK — only updating name |
| 14 | updateUser(2, { email: 'b@b.com', role: 'admin' }); // OK |
| 15 | |
| 16 | // Pick and Omit |
| 17 | type UserPublic = Omit<User, 'email' | 'role'>; // only id, name |
| 18 | type UserCredentials = Pick<User, 'email'>; // only email |
| 19 | |
| 20 | // Record — object with consistent value types |
| 21 | const scores: Record<string, number> = { |
| 22 | alice: 95, |
| 23 | bob: 87, |
| 24 | charlie: 92, |
| 25 | }; |
| 26 | |
| 27 | // Record with union keys |
| 28 | type Page = 'home' | 'about' | 'contact'; |
| 29 | const pages: Record<Page, { title: string; path: string }> = { |
| 30 | home: { title: 'Home', path: '/' }, |
| 31 | about: { title: 'About', path: '/about' }, |
| 32 | contact: { title: 'Contact', path: '/contact' }, |
| 33 | }; |
| 34 | |
| 35 | // ReturnType — extract function return type |
| 36 | function createUser(name: string, age: number) { |
| 37 | return { id: crypto.randomUUID(), name, age, createdAt: new Date() }; |
| 38 | } |
| 39 | type CreatedUser = ReturnType<typeof createUser>; |
| 40 | // { id: string; name: string; age: number; createdAt: Date } |
| 41 | |
| 42 | // Awaited — unwrap promises |
| 43 | async function fetchData(): Promise<{ items: string[] }> { |
| 44 | return { items: ['a', 'b'] }; |
| 45 | } |
| 46 | type Data = Awaited<ReturnType<typeof fetchData>>; |
| 47 | // { items: string[] } |
| 48 | |
| 49 | // Combination pattern |
| 50 | type UpdatePayload<Entity> = { |
| 51 | id: string; |
| 52 | changes: Partial<Entity>; |
| 53 | timestamp: number; |
| 54 | }; |
| 55 | |
| 56 | type UserUpdate = UpdatePayload<User>; |
pro tip
TypeScript and React are a powerful combination. TypeScript provides type safety for props, state, hooks, events, and refs. React's type definitions (@types/react) include comprehensive types for every React API. With TypeScript, many React bugs — passing wrong props, accessing state incorrectly, mishandling event types — are caught at compile time.
| 1 | // Typed React component (function component) |
| 2 | import React, { useState, useEffect, useCallback } from 'react'; |
| 3 | |
| 4 | interface ButtonProps { |
| 5 | label: string; |
| 6 | variant?: 'primary' | 'secondary' | 'danger'; |
| 7 | disabled?: boolean; |
| 8 | onClick: (event: React.MouseEvent<HTMLButtonElement>) => void; |
| 9 | children?: React.ReactNode; |
| 10 | } |
| 11 | |
| 12 | const Button: React.FC<ButtonProps> = ({ |
| 13 | label, |
| 14 | variant = 'primary', |
| 15 | disabled = false, |
| 16 | onClick, |
| 17 | children, |
| 18 | }) => { |
| 19 | return ( |
| 20 | <button |
| 21 | className={variant} |
| 22 | disabled={disabled} |
| 23 | onClick={onClick} |
| 24 | > |
| 25 | {label} |
| 26 | {children} |
| 27 | </button> |
| 28 | ); |
| 29 | }; |
| 30 | |
| 31 | // Typed useState |
| 32 | const [count, setCount] = useState<number>(0); |
| 33 | const [user, setUser] = useState<User | null>(null); |
| 34 | const [items, setItems] = useState<string[]>([]); |
| 35 | |
| 36 | // Typed useEffect |
| 37 | useEffect(() => { |
| 38 | const controller = new AbortController(); |
| 39 | fetch('/api/data', { signal: controller.signal }) |
| 40 | .then(res => res.json()) |
| 41 | .then((data: User[]) => setUser(data[0])); |
| 42 | return () => controller.abort(); |
| 43 | }, []); |
| 44 | |
| 45 | // Typed event handlers |
| 46 | const handleChange = useCallback( |
| 47 | (e: React.ChangeEvent<HTMLInputElement>) => { |
| 48 | console.log(e.target.value); |
| 49 | }, |
| 50 | [] |
| 51 | ); |
| 52 | |
| 53 | const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { |
| 54 | e.preventDefault(); |
| 55 | // handle form |
| 56 | }; |
| 57 | |
| 58 | // Typed refs |
| 59 | const inputRef = useRef<HTMLInputElement>(null); |
| 60 | const divRef = useRef<HTMLDivElement>(null); |
| 61 | |
| 62 | // useReducer with typed actions |
| 63 | type Action = |
| 64 | | { type: 'increment'; amount: number } |
| 65 | | { type: 'decrement'; amount: number } |
| 66 | | { type: 'reset' }; |
| 67 | |
| 68 | interface CounterState { |
| 69 | count: number; |
| 70 | } |
| 71 | |
| 72 | function counterReducer(state: CounterState, action: Action): CounterState { |
| 73 | switch (action.type) { |
| 74 | case 'increment': |
| 75 | return { count: state.count + action.amount }; |
| 76 | case 'decrement': |
| 77 | return { count: state.count - action.amount }; |
| 78 | case 'reset': |
| 79 | return { count: 0 }; |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | const [state, dispatch] = useReducer(counterReducer, { count: 0 }); |
info
Migrating an existing JavaScript project to TypeScript should be incremental. TypeScript is designed for gradual adoption — you can start with a single file and expand. The key is setting up the right configuration, enabling strict checking gradually, and using any and // @ts-ignore as temporary bridges while you add types.
| Step | Action | Config |
|---|---|---|
| 1 | Install TypeScript | npm i -D typescript @types/node |
| 2 | Create tsconfig.json | npx tsc --init |
| 3 | Rename .js to .ts | Start with utility files and helpers |
| 4 | Enable allowJs | allowJs: true |
| 5 | Add types gradually | Use JSDoc for .js, strict for .ts |
| 6 | Enable strict mode | strict: true |
| 1 | // tsconfig.json — recommended starter config |
| 2 | { |
| 3 | "compilerOptions": { |
| 4 | "target": "ES2020", |
| 5 | "module": "ESNext", |
| 6 | "moduleResolution": "bundler", |
| 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], |
| 8 | "jsx": "react-jsx", |
| 9 | "strict": true, |
| 10 | "noUncheckedIndexedAccess": true, |
| 11 | "noImplicitOverride": true, |
| 12 | "allowJs": true, // allow .js files alongside .ts |
| 13 | "checkJs": false, // set to true to check .js files too |
| 14 | "declaration": true, // generate .d.ts files |
| 15 | "declarationMap": true, |
| 16 | "sourceMap": true, |
| 17 | "outDir": "./dist", |
| 18 | "rootDir": "./src", |
| 19 | "skipLibCheck": true // faster compilation |
| 20 | }, |
| 21 | "include": ["src/**/*"], |
| 22 | "exclude": ["node_modules", "dist"] |
| 23 | } |
| 24 | |
| 25 | // JSDoc — add types to plain JavaScript before migration |
| 26 | /** |
| 27 | * @param {string} name |
| 28 | * @param {number} age |
| 29 | * @returns {{ name: string; age: number; isAdult: boolean }} |
| 30 | */ |
| 31 | function createPerson(name, age) { |
| 32 | return { name, age, isAdult: age >= 18 }; |
| 33 | } |
| 34 | |
| 35 | // // @ts-expect-error — suppress expected errors during migration |
| 36 | // @ts-expect-error — better than @ts-ignore |
| 37 | // Use when TypeScript flags a line that you intend to fix later |
| 38 | const oldApiResult = legacyFunction(); // @ts-expect-error |
| 39 | |
| 40 | // Incremental strictness — start lenient, tighten over time |
| 41 | // 1. noImplicitAny: false → true |
| 42 | // 2. strictNullChecks: false → true |
| 43 | // 3. strict: true (enables all strict flags) |
best practice
pro tip