iTranslated by AI
More Than Just Adding Types: 5 Design Flaws Discovered During My JS to TS Migration
Introduction
I recently had the opportunity to migrate an existing Vue 3 project (written in JavaScript) to TypeScript, starting from a beginner's perspective. In this article, I will organize and summarize the basic TypeScript knowledge I acquired during the implementation process, as well as my experience of how the act of "writing types" led to improvements in my software design.
Prerequisites:
- Absolute beginner to TypeScript
- Some experience with JavaScript (Vue 3, Pinia, etc.)
- Experience with other programming languages (Python)
Migrated File:
- SignalR (real-time communication) wrapper module (approx. 90 lines)
Tech Stack:
- Vue 3 (Composition API with
<script setup>) - TypeScript 5.7
- Vite
- SignalR (@microsoft/signalr)
My Initial Expectation:
"Isn't it just renaming .js to .ts and adding some types?"
What Actually Happened:
In the process of writing types, issues in the original code began to surface one after another, leading me to not only define types but to re-evaluate the implementation itself.
Structure of the Migrated Code
Key functions of the SignalR wrapper module:
// Connection management
export async function connectSignalR(): Promise<HubConnection>
export async function disconnectSignalR(): Promise<void>
// Event listeners (each function returns a cleanup function)
export function onDeviceUpdated(callback: (data: DeviceUpdate) => void): () => void
export function onUserUpdated(callback: (data: UserUpdate) => void): () => void
export function onUserDeleted(callback: (data: UserDelete) => void): () => void
5 Issues I Discovered During the TypeScript Migration
1. Memory Leaks - Unable to remove event handlers
Original Code (JavaScript)
export function onDeviceUpdated(callback) {
connection.on('deviceUpdated', callback)
}
What is the problem?
When using onDeviceUpdated in a Vue component, there is no way to remove the event handler when the component is unmounted.
Repeatedly opening and closing the component causes event handlers to accumulate, leading to a memory leak.
Realization during TypeScript migration
I was pointed out in a CodeRabbit review that "a cleanup function should be returned to prevent memory leaks." Remembering that other libraries (e.g., addEventListener) use a pattern of returning a removal function, I reconsidered the return type.
After Fix
export function onDeviceUpdated(
callback: (data: DeviceUpdate) => void
): () => void { // ← Returns a cleanup function
connection?.on('deviceUpdated', callback)
return () => connection?.off('deviceUpdated', callback)
}
Usage (Vue Composition API)
onMounted(() => {
const cleanup = onDeviceUpdated((data) => { ... })
onUnmounted(cleanup) // Remove on unmount
})
What I learned: Explicitly writing out types gave me the opportunity to think about "what should this function actually return?"
2. No response validation - Unclear error messages
Original Code (JavaScript)
const response = await fetch(NEGOTIATE_URL)
const { url, accessToken } = await response.json()
What is the problem?
If the API response is not in the expected format, url or accessToken becomes undefined.
However, in JavaScript, this does not throw an error immediately, leading to obscure "Cannot read property 'xxx' of undefined" errors in subsequent processing.
Realization during TypeScript migration
I was pointed out in a CodeRabbit review that "there is no validation for the negotiate response."
interface NegotiateResponse {
url: string
accessToken: string
}
const data = await response.json() // ← This is an 'any' type!
I realized that since TypeScript provides compile-time type checking, it does not guarantee the types of API responses received at runtime.
After Fix
const data = await response.json()
// Validate the structure at runtime
if (!data || typeof data !== 'object') {
throw new Error('negotiate response is not an object')
}
if (typeof data.url !== 'string' || !data.url) {
throw new Error('negotiate response missing or invalid "url"')
}
if (typeof data.accessToken !== 'string' || !data.accessToken) {
throw new Error('negotiate response missing or invalid "accessToken"')
}
// Type assertion (safe because it has been validated)
const { url, accessToken } = data as NegotiateResponse
What I learned:
- TypeScript types = Compile-time only (checks during development)
- Runtime validation = Execution safety (necessary for production environments)
- It is safe to use
as(type assertion) only after validation
3. No timeout - fetch waits indefinitely
Original Code (JavaScript)
const response = await fetch(NEGOTIATE_URL)
What is the problem?
In case of network issues or server unresponsiveness, fetch might wait indefinitely. The user is left hanging with a "loading..." state.
Realization during TypeScript migration
A CodeRabbit review pointed out that "there is no timeout for fetch." While writing the types for the Promise, I realized I had only been considering the success scenario.
After Fix
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
try {
const response = await fetch(NEGOTIATE_URL, {
signal: controller.signal // Timeout signal
})
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('negotiate request timeout (5s)')
}
throw error
} finally {
clearTimeout(timeoutId) // Always clean up
}
What I learned:
-
AbortController= Browser standard API for cancelling fetch requests -
finally= Executes regardless of success or failure - When writing types for Promises, always consider the reject (failure) cases
4. Hardcoded URL - Cannot switch environments
Original Code (JavaScript)
const NEGOTIATE_URL = 'https://production-api.example.com/api/negotiate'
What is the problem?
The URL is fixed, even though different URLs might be needed for development (dev), staging, and production environments. Modifying the code for each environment is not practical.
Realization during TypeScript migration
A CodeRabbit review pointed out that "the URL is hardcoded." While looking at the Vite type definition file (env.d.ts), I learned that environment variables can be used via import.meta.env.
After Fix
const NEGOTIATE_URL =
import.meta.env.VITE_SIGNALR_NEGOTIATE_URL ||
'https://production-api.example.com/api/negotiate'
Define environment variables in the .env file:
# .env.development
VITE_SIGNALR_NEGOTIATE_URL=http://localhost:7071/api/negotiate
# .env.production
VITE_SIGNALR_NEGOTIATE_URL=https://production-api.example.com/api/negotiate
What I learned:
- In Vite, environment variables with the
VITE_prefix are available viaimport.meta.env - By setting a fallback value (
||), it is safe even if the environment variable is missing - TypeScript allows defining types for environment variables in
env.d.ts
5. Reusing Old Tokens - Authentication Error During Auto-Reconnect
Original Code (JavaScript)
const { url, accessToken } = await response.json()
connection = new HubConnectionBuilder()
.withUrl(url, {
accessTokenFactory: () => accessToken // Keeps the initial token
})
.withAutomaticReconnect() // Uses the old token even during reconnection!
.build()
What is the problem?
SignalR has an automatic reconnection feature (.withAutomaticReconnect()). However, if the initial access token is used during reconnection, an authentication error occurs if the token has expired.
Realization during TypeScript migration
A CodeRabbit review pointed out that "the old token is being reused during automatic reconnection."
Checking the type definition for accessTokenFactory revealed that it can also accept Promise<string>:
// SignalR type definition
accessTokenFactory?: () => string | Promise<string>
I learned that when capturing variables with a closure, it is necessary to be aware of the lifetime of those values (how long they remain valid).
After Fix
connection = new HubConnectionBuilder()
.withUrl(url, {
accessTokenFactory: async () => {
// Get a new token upon each reconnection
try {
const response = await fetch(NEGOTIATE_URL)
const data = await response.json()
return data.accessToken || accessToken // Fallback
} catch {
return accessToken // Use initial token on error
}
}
})
.build()
What I learned:
- Closures = A mechanism where a function "captures" external variables
- Be aware of the lifetime of captured values
- You can write asynchronous processing with
async () => { ... }(functions that return a Promise)
Learned About TypeScript Type Definitions as Well
Optional Chaining ?.
connection?.on('deviceUpdated', callback)
// ↑ Do nothing if connection is null
// Same as this
if (connection) {
connection.on('deviceUpdated', callback)
}
Type for a Function Returning a Function
(): () => void
// ↑ Return type = "Function with no arguments and no return value"
// Usage
const cleanup: () => void = onDeviceUpdated(callback)
cleanup() // Can be called later
Type Definition with interface
interface NegotiateResponse {
url: string
accessToken: string
}
// Example usage
const data: NegotiateResponse = await response.json()
data.url // OK
data.token // Error: Property 'token' does not exist
TypeScript Enhanced My "Ability to Notice"
These 5 problems were all things that could not be found by TypeScript type checking alone.
| Problem | TypeScript Type Check | Actual Discovery Method |
|---|---|---|
| Memory leak | ❌ Compiles fine | Noticed when writing the "return type" |
| Response validation | ❌ Passes with any type |
Wondered, "Is this type really guaranteed?" |
| Timeout | ❌ Compiles fine | Thought about failure cases for Promise
|
| Environment variables | ❌ Compiles fine | Noticed while reading env.d.ts
|
| Token reuse | ❌ Compiles fine | Noticed by reading type definitions and thinking "Can this be async?" |
TypeScript's Value = Not Just Type Checking
By explicitly writing out types, I created a checklist for my thinking process:
- "What should this function return?"
- "How trustworthy is this value?"
- "Have I considered failure patterns?"
Design flaws that I had overlooked in JavaScript by thinking "if it works, it's fine" were visualized by TypeScript.
By combining Claude Code and CodeRabbit, I was able to effectively discover problems. The biggest gain was successfully cycling through writing types and learning.
Summary
Migrating to TypeScript was not just "renaming .js to .ts and writing types."
As I wrote types, the following questions arose one after another:
- "What should this function return?" → Added cleanup functions
- "Is this type really guaranteed?" → Added runtime validation
- "Have I considered failure cases?" → Implemented timeouts
- "Should this value change per environment?" → Introduced environment variables
- "How long is this variable valid?" → Implemented token updates
TypeScript was a tool that increased my "ability to notice."
The act of "writing explicitly," forced by the type system, made the flaws in my design visible.
I will migrate the remaining 10 files next!
Migration Tips:
- Don't rush, take it one file at a time
- Think "Why?" while writing types
- Read the library's type definitions (a treasure trove of learning)
- Utilize AI reviews (CodeRabbit, etc.)
Discussion