JavaScript to TypeScript: Why I Finally Made the Switch and How It Changed My Development
A developer's journey from JavaScript to TypeScript. Learn why type safety matters, how TypeScript catches bugs before runtime, and real migration strategies with practical examples. Discover the productivity boost of static typing and modern tooling.

The Bug That Convinced Me TypeScript Was Worth It
It was 2 AM. Production was down. Users couldn't log in.
The error? Cannot read property 'id' of undefined.
I'd passed an object to a function expecting different properties. A typo. One wrong key name. JavaScript happily executed the code until it crashed at runtime.
Cost: 3 hours of downtime, angry users, and a very unpleasant on-call shift.
The fix: One line. Adding the correct property name.
The realization: TypeScript would have caught this at compile time. Before deployment. Before users saw it.
That night, I decided to learn TypeScript seriously. Three months later, my entire personal codebase was migrated. Six months later, I was advocating for TypeScript adoption at work.
In this post, I'll share why I migrated from JavaScript to TypeScript, how the migration actually works, and the real productivity gains I experienced.
My JavaScript Background: Years of Dynamic Typing
Starting with JavaScript
I learned web development with JavaScript. It was:
- Easy to start: No compilation, just write and run
- Flexible: Duck typing let me code freely
- Forgiving: Type coercion handled my mistakes
- Fast to prototype: No type annotations needed
For years, JavaScript was enough. I built:
- Frontend apps with React and Vue
- Backend APIs with Node.js and Express
- Full-stack projects with Next.js and Nuxt
- Utility libraries for data manipulation
When JavaScript Became a Problem
As projects grew, issues emerged:
1. Runtime errors that should have been caught earlier:
// JavaScript - looks fine, breaks at runtime
function getUserName(user) {
return user.profile.name; // What if user is null? What if profile doesn't exist?
}
// Hours later, in production:
// TypeError: Cannot read property 'profile' of null
2. Refactoring was terrifying:
Change one function signature, hope you found all the call sites. No compiler to verify.
3. IDE couldn't help much:
IntelliSense was guessing. Autocomplete was hit or miss. No parameter hints for custom functions.
4. Documentation lived separately:
// JavaScript - JSDoc helps, but not enforced
/**
* @param {string} id - User ID
* @param {Object} options - Configuration options
* @returns {Promise<User>}
*/
async function fetchUser(id, options) {
// Nothing stops me from passing numbers or forgetting parameters
}
5. API responses were black boxes:
// What shape is this data? Who knows!
const response = await fetch('/api/users');
const users = await response.json();
// Hope and pray the API didn't change
users.forEach(user => {
console.log(user.name); // Does 'name' exist? Is it 'username' now?
});
The Turning Point: Production Bugs from Type Issues
The Database Migration Incident
We updated our database schema. Changed user_id to userId (camelCase).
The backend was updated. The frontend... mostly updated.
One file still used user_id. JavaScript didn't complain. It compiled. It deployed.
Users couldn't save their profiles. The property was undefined. Data wasn't persisting.
TypeScript would have caught this instantly:
interface User {
userId: string; // Changed from user_id
name: string;
}
// This would error at compile time:
const user: User = {
user_id: '123', // Error: 'user_id' does not exist on type 'User'
name: 'John',
};
The API Contract Break
A third-party API changed their response structure. We didn't notice until production.
Old response:
{
"data": {
"items": [...]
}
}
New response:
{
"results": {
"items": [...]
}
}
Our code accessed response.data.items. Got undefined. App broke.
With TypeScript:
// Define expected shape
interface APIResponse {
data: {
items: Item[];
};
}
// Type error immediately when API changes
const response: APIResponse = await fetchAPI();
These incidents pushed me over the edge. I needed TypeScript.
Starting the Migration: Week One
Setting Up TypeScript
Step 1: Install TypeScript
npm install --save-dev typescript @types/node
Step 2: Initialize Configuration
npx tsc --init
This created tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Key settings explained:
strict: true- Enables all strict type-checking optionsnoEmit: true- Don't generate JS files (let bundler handle it)esModuleInterop: true- Better compatibility with CommonJS modules
My First TypeScript File
I started with a utility file. Small, isolated, no dependencies.
Before (JavaScript):
// utils.js
export function formatDate(date) {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export function calculateAge(birthDate) {
const today = new Date();
const birth = new Date(birthDate);
return today.getFullYear() - birth.getFullYear();
}
After (TypeScript):
// utils.ts
export function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export function calculateAge(birthDate: string | Date): number {
const today = new Date();
const birth = typeof birthDate === 'string'
? new Date(birthDate)
: birthDate;
return today.getFullYear() - birth.getFullYear();
}
What changed:
- Added parameter types (
date: Date,birthDate: string | Date) - Added return types (
: string,: number) - TypeScript now catches misuse at compile time
Immediate benefit:
// TypeScript catches this:
formatDate('2024-01-01'); // Error: Argument of type 'string' is not assignable to parameter of type 'Date'
// Must do this:
formatDate(new Date('2024-01-01')); // ā Correct
The Learning Curve
Week 1: Frustration
Every file I converted had errors. Red squiggly lines everywhere.
// TypeScript complained about everything
const data = await fetch('/api').then(r => r.json()); // Type 'any'
const user = data.user; // Property 'user' does not exist on type 'any'
"This is too much work," I thought.
Week 2: Understanding
TypeScript wasn't being difficult. It was showing me where my code was unsafe.
// Define types for API responses
interface APIResponse {
user: User;
token: string;
}
interface User {
id: string;
name: string;
email: string;
}
// Now TypeScript helps instead of complains
const data: APIResponse = await fetch('/api').then(r => r.json());
const user = data.user; // TypeScript knows user is User type
console.log(user.name); // Autocomplete works!
Week 3: Productivity Gains
I was writing code faster. Fewer bugs. Better refactoring. IntelliSense actually helped.
Week 4: Addicted
I couldn't go back to JavaScript. TypeScript's benefits were too obvious.
Real Migration Examples from My Projects
Example 1: API Client
JavaScript version (error-prone):
// api.js
export async function fetchUser(userId) {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return data;
}
export async function updateUser(userId, updates) {
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
return response.json();
}
// Usage - no safety
const user = await fetchUser(123); // Passed number instead of string - runtime error!
await updateUser(user.id, { nam: 'John' }); // Typo in 'nam' - silent failure
TypeScript version (type-safe):
// api.ts - from my actual project structure
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
interface UpdateUserDTO {
name?: string;
email?: string;
}
export async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return data as User; // Runtime validation should also be added
}
export async function updateUser(
userId: string,
updates: UpdateUserDTO
): Promise<User> {
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
return response.json();
}
// Usage - compile-time safety
const user = await fetchUser('123'); // ā Correct type
await updateUser(user.id, { name: 'John' }); // ā Correct properties
await updateUser(user.id, { nam: 'John' }); // ā Error: Object literal may only specify known properties
Benefits:
- Can't pass wrong types
- Can't typo property names
- Autocomplete shows available properties
- Refactoring is safe
Example 2: React Component
JavaScript version:
// BlogCard.jsx
export default function BlogCard({ blog, onRead, featured }) {
return (
<div className={featured ? 'featured' : ''}>
<h3>{blog.title}</h3>
<p>{blog.excerpt}</p>
<button onClick={() => onRead(blog.id)}>
Read More
</button>
</div>
);
}
// Usage - easy to make mistakes
<BlogCard blog={blogPost} onRead={handleRead} /> // Forgot 'featured', no warning
<BlogCard blog={blogPost} onClick={handleRead} /> // Wrong prop name, silently fails
TypeScript version:
// BlogCard.tsx - similar to my actual Vue components
interface Blog {
id: string;
title: string;
excerpt: string;
image?: string;
}
interface BlogCardProps {
blog: Blog;
onRead: (id: string) => void;
featured?: boolean;
}
export default function BlogCard({
blog,
onRead,
featured = false
}: BlogCardProps) {
return (
<div className={featured ? 'featured' : ''}>
<h3>{blog.title}</h3>
<p>{blog.excerpt}</p>
<button onClick={() => onRead(blog.id)}>
Read More
</button>
</div>
);
}
// Usage - errors if wrong
<BlogCard blog={blogPost} onRead={handleRead} /> // ā Works
<BlogCard blog={blogPost} onClick={handleRead} /> // ā Error: Property 'onClick' does not exist
<BlogCard blog={blogPost} onRead="invalid" /> // ā Error: Type 'string' is not assignable to type '(id: string) => void'
Example 3: Pinia Store (from my actual project)
JavaScript version:
// stores/AlertStore.js
import { defineStore } from 'pinia';
export const useAlertStore = defineStore('alert', () => {
const alerts = ref([]);
function addAlert(message, type) {
alerts.value.push({
id: Date.now(),
message,
type,
});
}
function removeAlert(id) {
alerts.value = alerts.value.filter(a => a.id !== id);
}
return { alerts, addAlert, removeAlert };
});
TypeScript version (actual code from my project):
// stores/AlertStore.ts
import { defineStore } from 'pinia';
export type AlertType = 'success' | 'error' | 'warning' | 'info';
export interface Alert {
id: number;
message: string;
type: AlertType;
}
export const useAlertStore = defineStore('alert', () => {
const alerts = ref<Alert[]>([]);
function addAlert(message: string, type: AlertType): void {
const id = Date.now();
alerts.value.push({ id, message, type });
// Auto-remove after 5 seconds
setTimeout(() => removeAlert(id), 5000);
}
function removeAlert(id: number): void {
alerts.value = alerts.value.filter(a => a.id !== id);
}
return { alerts, addAlert, removeAlert };
});
Usage benefits:
// In any component
const alertStore = useAlertStore();
// TypeScript validates everything
alertStore.addAlert('Success!', 'success'); // ā Valid
alertStore.addAlert('Error!', 'danger'); // ā Error: 'danger' is not assignable to type AlertType
alertStore.removeAlert('123'); // ā Error: Argument of type 'string' is not assignable to parameter of type 'number'
TypeScript Features That Changed Everything
1. Interface Definitions
Before: Documentation lived in comments (often outdated)
// What does this function expect? Check the code or docs (maybe)
function processOrder(order) {
console.log(order.items, order.total, order.customer);
}
After: Types are the documentation
interface OrderItem {
productId: string;
quantity: number;
price: number;
}
interface Customer {
id: string;
name: string;
email: string;
}
interface Order {
id: string;
items: OrderItem[];
total: number;
customer: Customer;
status: 'pending' | 'processing' | 'shipped' | 'delivered';
}
function processOrder(order: Order): void {
// IDE knows exactly what properties exist
console.log(order.items, order.total, order.customer);
}
2. Union Types and Literal Types
Restricting values to specific options:
// Can only be one of these values
type Status = 'idle' | 'loading' | 'success' | 'error';
function setStatus(status: Status): void {
// TypeScript prevents typos
// status = 'loaded'; // Error: Type '"loaded"' is not assignable to type 'Status'
}
// Union of different types
type ID = string | number;
function getUser(id: ID): User {
// Can handle both
}
3. Optional Properties and Parameters
interface Config {
apiKey: string;
timeout?: number; // Optional
retries?: number; // Optional
}
function initialize(config: Config): void {
const timeout = config.timeout ?? 5000; // Default if undefined
const retries = config.retries ?? 3;
}
// Valid calls
initialize({ apiKey: 'abc123' });
initialize({ apiKey: 'abc123', timeout: 10000 });
initialize({ apiKey: 'abc123', timeout: 10000, retries: 5 });
4. Generics for Reusable Types
// Generic API response wrapper
interface APIResponse<T> {
data: T;
status: number;
message: string;
}
// Reuse with different types
type UserResponse = APIResponse<User>;
type PostsResponse = APIResponse<Post[]>;
async function fetchAPI<T>(url: string): Promise<APIResponse<T>> {
const response = await fetch(url);
return response.json();
}
// TypeScript infers the return type
const users = await fetchAPI<User[]>('/api/users'); // Type: APIResponse<User[]>
const post = await fetchAPI<Post>('/api/posts/1'); // Type: APIResponse<Post>
5. Type Guards for Runtime Safety
interface Dog {
bark(): void;
}
interface Cat {
meow(): void;
}
type Pet = Dog | Cat;
// Type guard
function isDog(pet: Pet): pet is Dog {
return 'bark' in pet;
}
function playWith(pet: Pet): void {
if (isDog(pet)) {
pet.bark(); // TypeScript knows it's a Dog here
} else {
pet.meow(); // TypeScript knows it's a Cat here
}
}
6. Utility Types
TypeScript provides built-in utility types:
interface User {
id: string;
name: string;
email: string;
password: string;
}
// Pick specific properties
type UserPublic = Pick<User, 'id' | 'name' | 'email'>;
// { id: string; name: string; email: string; }
// Omit specific properties
type UserWithoutPassword = Omit<User, 'password'>;
// { id: string; name: string; email: string; }
// Make all properties optional
type PartialUser = Partial<User>;
// { id?: string; name?: string; email?: string; password?: string; }
// Make all properties required
type RequiredUser = Required<PartialUser>;
// { id: string; name: string; email: string; password: string; }
// Make all properties readonly
type ReadonlyUser = Readonly<User>;
// { readonly id: string; readonly name: string; ... }
My Current TypeScript Stack
After 6 months of full TypeScript adoption:
Personal Projects:
{
"dependencies": {
"nuxt": "^4.2.0",
"vue": "^3.4.0",
"@nuxt/content": "^3.7.1",
"pinia": "^3.0.1"
},
"devDependencies": {
"typescript": "^5.8.2",
"@types/node": "^20.0.0",
"@nuxt/eslint": "^1.2.0",
"eslint": "^9.22.0"
}
}
Project structure (all TypeScript):
src/
āāā components/
ā āāā *.vue (with TypeScript)
āāā composables/
ā āāā *.ts
āāā stores/
ā āāā *.ts
āāā utils/
ā āāā constants.ts
ā āāā methods.ts
ā āāā types.ts
āāā pages/
ā āāā *.vue (with TypeScript)
āāā nuxt.config.ts
Real example from my utils/types.ts:
// types.ts - from my actual project
export type AlertType = 'success' | 'error' | 'warning' | 'info';
export interface Alert {
id: number;
message: string;
type: AlertType;
}
export interface BlogPost {
id: string;
title: string;
description: string;
content: string;
published_at: Date;
updated_at: Date;
tags: string[];
image?: string;
primary_category: web-development
secondary_categories: string[];
}
export interface Project {
id: string;
title: string;
description: string;
technologies: string[];
github?: string;
demo?: string;
image: string;
}
The Productivity Gains I Experienced
1. Bugs Caught Before Runtime
Before TypeScript:
- Write code
- Run app
- Find bug in browser
- Fix
- Repeat
After TypeScript:
- Write code
- TypeScript errors appear immediately
- Fix before even running
- App works first try (usually)
Estimated time saved: 30% debugging time reduction
2. Refactoring Confidence
Before:
Changing a function signature meant:
- Hope you remember all call sites
- Search codebase manually
- Run app and test everything
- Deploy and pray
After:
- Change function signature
- TypeScript shows all errors
- Fix each one
- Deploy with confidence
Example:
// Changed from:
function getUserName(user: User): string {
return user.name;
}
// To:
function getUserName(user: User | null): string | null {
return user?.name ?? null;
}
// TypeScript immediately showed 23 places that needed updating
// Fixed all in 5 minutes instead of hunting bugs for hours
3. IDE Superpowers
Autocomplete that actually works:
const user: User = {
id: '123',
name: 'John',
email: 'john@example.com',
};
user. // IDE shows: id, name, email (exactly these properties)
Go to definition:
Click any type or function, instantly jump to its definition.
Find all references:
See everywhere a type or function is used.
Rename symbol:
Rename safely across entire project.
4. Self-Documenting Code
Types serve as inline documentation:
// No comments needed - types tell the story
async function fetchUserPosts(
userId: string,
options: {
limit?: number;
offset?: number;
sortBy?: 'date' | 'title' | 'views';
} = {}
): Promise<Post[]> {
// Implementation
}
Before, this would need JSDoc comments. After, the types are the documentation.
5. Team Collaboration
With TypeScript:
- New team members understand code faster
- API contracts are explicit
- Less time explaining types in PRs
- Fewer "what does this parameter expect?" questions
Migration Strategies That Worked
Strategy 1: Start with New Files
Don't convert everything at once:
- Configure
tsconfig.jsonto allow JS files - Write all new code in TypeScript
- Gradually convert old files when you touch them
{
"compilerOptions": {
"allowJs": true, // Allow .js files
"checkJs": false, // Don't type-check .js files yet
"strict": true // Strict mode for .ts files
}
}
Strategy 2: Convert Layer by Layer
My approach:
- Week 1-2: Convert utility files and constants
- Week 3-4: Convert type definitions and interfaces
- Week 5-6: Convert stores and composables
- Week 7-8: Convert components
- Week 9-10: Convert pages
Strategy 3: Use any Temporarily
Don't let perfect be the enemy of good:
// Temporary - mark for future improvement
const data: any = await complexLegacyFunction();
// Add a TODO
// TODO: Type this properly when refactoring legacy code
Strategy 4: Add Types Incrementally
Start loose, tighten gradually:
// Step 1: Basic types
function process(data: any): any {
// ...
}
// Step 2: Add return type
function process(data: any): ProcessedData {
// ...
}
// Step 3: Add parameter type
function process(data: InputData): ProcessedData {
// ...
}
// Step 4: Add generics if needed
function process<T extends InputData>(data: T): ProcessedData<T> {
// ...
}
Common TypeScript Mistakes I Made (And How to Avoid Them)
Mistake 1: Using any Everywhere
Wrong:
function fetchData(): any {
// Defeats the purpose of TypeScript
}
Right:
interface Data {
id: string;
value: number;
}
function fetchData(): Promise<Data> {
// TypeScript can help now
}
Mistake 2: Not Using Union Types
Wrong:
function setStatus(status: string): void {
// Allows any string, even invalid ones
}
Right:
function setStatus(status: 'idle' | 'loading' | 'success' | 'error'): void {
// Only allows valid statuses
}
Mistake 3: Ignoring Null/Undefined
Wrong:
function getUser(id: string): User {
// What if user not found?
return users.find(u => u.id === id); // Error: Type 'User | undefined' is not assignable to type 'User'
}
Right:
function getUser(id: string): User | null {
return users.find(u => u.id === id) ?? null;
}
// Or use optional
function getUser(id: string): User | undefined {
return users.find(u => u.id === id);
}
Mistake 4: Not Leveraging Type Inference
Unnecessary:
const name: string = 'John'; // TypeScript already knows it's string
const count: number = 42; // TypeScript already knows it's number
Better:
const name = 'John'; // TypeScript infers string
const count = 42; // TypeScript infers number
// Only add types when needed
const user: User = await fetchUser(); // Good - clarifies complex type
TypeScript in Different Contexts
Frontend (Vue/React)
// Vue component with TypeScript
<script setup lang="ts">
interface Props {
title: string;
count?: number;
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
});
const doubled = computed(() => props.count * 2);
</script>
Backend (Node.js/Express)
import express, { Request, Response } from 'express';
interface CreateUserBody {
name: string;
email: string;
}
app.post('/users', async (req: Request<{}, {}, CreateUserBody>, res: Response) => {
const { name, email } = req.body; // Typed!
const user = await createUser(name, email);
res.json(user);
});
Configuration Files
// nuxt.config.ts - from my actual project
export default defineNuxtConfig({
modules: [
'@nuxt/ui',
'@nuxt/content',
'@pinia/nuxt',
],
ssr: true,
routeRules: {
'/': { prerender: true },
'/blog/**': { prerender: true },
},
typescript: {
strict: true,
typeCheck: true,
},
});
The Verdict: Was It Worth It?
After 6 months of full TypeScript:
Productivity: 10/10
- Faster development
- Fewer bugs
- Better refactoring
- Less debugging time
Learning Curve: 7/10
- First week: frustrating
- First month: productive
- After 3 months: can't imagine going back
Tooling: 10/10
- Amazing IDE support
- Instant error detection
- Perfect autocomplete
- Reliable refactoring
Ecosystem: 9/10
- Most libraries have types
- DefinitelyTyped has the rest
- Great community support
Team Collaboration: 9/10
- Code is self-documenting
- Fewer misunderstandings
- Easier onboarding
- Better code reviews
Overall: Would I migrate again? Absolutely yes.
When to Use TypeScript
Use TypeScript if:
1. Building anything beyond a small script: Type safety scales with project size
2. Working in a team: Explicit contracts prevent miscommunication
3. Building a library or SDK: Users benefit from types
4. Long-term maintenance matters: Future you will thank present you
5. Refactoring is common: Compiler catches all breaking changes
Maybe skip TypeScript if:
1. Tiny throw-away script: Not worth the setup
2. Extreme prototyping: When you're changing everything every hour
3. Team refuses to learn: TypeScript without buy-in is painful
Conclusion: The Type Safety I Wish I Had Earlier
That 2 AM production bug taught me something valuable: catching errors at compile time is infinitely better than discovering them in production.
TypeScript isn't just about types. It's about:
- Confidence: Deploy without fear
- Speed: IDE helps instead of guessing
- Maintainability: Code documents itself
- Collaboration: Clear contracts between modules
- Quality: Bugs caught before users see them
If you're still writing JavaScript:
- Try TypeScript for one project
- Start with loose types, tighten gradually
- Give it a month before judging
- Experience the IDE improvements
- Feel the safety net
For me? TypeScript is now mandatory. I won't start new projects without it.
The migration took effort. The benefits are permanent.
Sometimes the best tools are the ones that prevent problems instead of fixing them. TypeScript is that tool.
If you're considering migrating to TypeScript or want to discuss type safety strategies, I'd love to hear from you! Connect with me on Twitter or LinkedIn and let's talk about our development experiences!
Support My Work
If this guide helped you understand TypeScript better, made your migration decision easier, or gave you practical strategies for adoption, I'd really appreciate your support! Creating comprehensive TypeScript migration guides with real-world examples takes significant time and effort. Your support helps me continue sharing knowledge and creating more helpful resources for developers.
ā Buy me a coffee - Every contribution, big or small, means the world to me and keeps me motivated to create more content!
Photo by Victoria Tronina on Unsplash