TypeScript Refactoring Interview Questions
How to spot weak TypeScript code, explain the problem, and refactor it into safer, cleaner, more maintainable code.
TypeScript interviews are changing.
A few years ago, many interviews focused on syntax: what is a union type, how generics work, what Partial<T> does, or how to type a function parameter. Those questions still appear, but stronger interviews now go further. They test whether a developer can look at code that technically works and still notice where it is fragile.
That is the real skill.
A good TypeScript developer does not only ask:
“Does this compile?”
They also ask:
“Can this represent invalid data?”
“Will this still be safe after the next refactor?”
“Does this type describe the real domain?”
“Is the compiler helping us, or are we fighting it?”
“Will another developer understand this in six months?”
1. Narrow Types When TypeScript Infers Too Broadly
One common interview question sounds like this:
“TypeScript inferred a type that is too broad. How would you fix it, and why does it matter?”
This is a great question because it tests whether the candidate understands inference, literal types, generics, and practical safety.
Here is a weak version:
const ageByUsername = new Map()
ageByUsername.set('nina', 31)
ageByUsername.set('mark', 'unknown')TypeScript infers:
Map<any, any>That means the map accepts anything as a key and anything as a value. The compiler is no longer protecting the code.
A safer version:
const ageByUsername = new Map<string, number>()
ageByUsername.set('nina', 31)
ageByUsername.set('mark', 42)
// Error:
// Argument of type 'string' is not assignable to parameter of type 'number'.
ageByUsername.set('alex', 'unknown')Now the type communicates intent:
keys must be strings
values must be numbers
incorrect values fail during development
The same issue appears often with React state:
import { useState } from 'react'
const [paymentStatus, setPaymentStatus] = useState('idle')At first glance, this looks fine. But in many cases TypeScript may infer a broad string, depending on how the state is used. That allows invalid states:
setPaymentStatus('banana')
setPaymentStatus('something-went-wrong')A better version:
type PaymentStatus = 'idle' | 'processing' | 'paid' | 'failed'
const [paymentStatus, setPaymentStatus] =
useState<PaymentStatus>('idle')
setPaymentStatus('processing')
setPaymentStatus('paid')
// Error
setPaymentStatus('banana')This is not just “more typing.” It prevents impossible UI states.
A strong interview answer should explain that explicit types are useful when they narrow the possible values and encode business rules.
2. Do Not Annotate What TypeScript Already Knows
Another common interview question:
“When should you write an explicit type annotation, and when should you let TypeScript infer it?”
Beginners often over-annotate everything:
const username: string = 'nina'
const retryCount: number = 3
const isDialogOpen: boolean = falseThis is not harmful, but it is noisy. TypeScript already knows these types.
A cleaner version:
const username = 'nina'
const retryCount = 3
const isDialogOpen = falseThe important point is this:
Use explicit annotations when they add useful information.
For example, this annotation is useful:
type SubscriptionPlan = 'free' | 'pro' | 'team'
let selectedPlan: SubscriptionPlan = 'free'
selectedPlan = 'team'
selectedPlan = 'enterprise' // ErrorWithout the annotation, TypeScript may infer the variable as string in some mutable contexts:
let selectedPlan = 'free'
selectedPlan = 'enterprise' // AllowedThat may not be what the application wants.
For arrays and maps, inference is often good enough:
const productPrices = new Map([
['keyboard', 129],
['mouse', 79],
])TypeScript infers:
Map<string, number>There is no need to repeat the type unless the empty initialization loses information:
const productPrices = new Map<string, number>()A good rule:
Let TypeScript infer obvious implementation details. Add explicit types when they protect boundaries, narrow values, or document public APIs.
3. Protect Function Inputs with Readonly Types
Interview question:
“How can you prevent a function from accidentally mutating its input?”
Bad version:
type Customer = {
id: string
name: string
}
function removeFirstCustomer(customers: Array<Customer>) {
if (customers.length === 0) {
return customers
}
return customers.splice(1)
}The problem is splice.
It mutates the original array.
const customers = [
{ id: 'c1', name: 'Nina' },
{ id: 'c2', name: 'Mark' },
]
const remainingCustomers = removeFirstCustomer(customers)
console.log(customers)
// Original array was changedA safer version:
type Customer = {
id: string
name: string
}
function withoutFirstCustomer(
customers: ReadonlyArray<Customer>,
): Array<Customer> {
return customers.slice(1)
}Now two things are improved:
The function name describes the result.
ReadonlyArray<Customer>prevents accidental mutation.
Try this:
function withoutFirstCustomer(
customers: ReadonlyArray<Customer>,
): Array<Customer> {
customers.splice(1)
return []
}TypeScript complains because splice is not available on ReadonlyArray.
This is a strong refactoring pattern:
function calculateCartTotal(
items: ReadonlyArray<CartItem>,
): number {
return items.reduce((total, item) => {
return total + item.price * item.quantity
}, 0)
}The function only reads data. The type should say that.
Readonly types help with:
safer utility functions
easier debugging
fewer hidden side effects
better tests
cleaner React state updates
4. Do Not Make Every Field Optional
Interview question:
“Why is it dangerous to make every property optional?”
Weak version:
type UserSession = {
id?: string
email?: string
token?: string
expiresAt?: Date
isAdmin?: boolean
}This type looks flexible, but it is actually vague.
It allows almost anything:
const sessionA: UserSession = {}
const sessionB: UserSession = { email: 'nina@example.com' }
const sessionC: UserSession = { isAdmin: true }Now every consumer must defend against missing fields:
function getSessionEmail(session: UserSession): string {
return session.email?.toLowerCase() ?? 'anonymous'
}Sometimes optional fields are correct. But making everything optional usually means the type does not represent the real domain.
A better model uses clear states:
type AuthenticatedSession = {
kind: 'authenticated'
id: string
email: string
token: string
expiresAt: Date
permissions: ReadonlyArray<string>
}
type AnonymousSession = {
kind: 'anonymous'
trackingId: string
}
type ExpiredSession = {
kind: 'expired'
expiredAt: Date
}
type UserSession =
| AuthenticatedSession
| AnonymousSession
| ExpiredSessionNow each state has exact fields.
function getDisplayName(session: UserSession): string {
switch (session.kind) {
case 'authenticated':
return session.email
case 'anonymous':
return `Guest ${session.trackingId}`
case 'expired':
return 'Expired session'
}
}Inside each branch, TypeScript knows which fields exist.
This is much better than optional-property soup.
5. Replace Boolean Flags with Discriminated Unions
Interview question:
“What is a discriminated union, and how can it improve state modeling?”
Bad version:
type ProductRequest = {
isLoading?: boolean
isSuccess?: boolean
isError?: boolean
data?: Array<Product>
errorMessage?: string
}This type allows impossible states:
const request: ProductRequest = {
isLoading: true,
isSuccess: true,
isError: true,
data: [],
errorMessage: 'Something failed',
}Is it loading? Successful? Failed?
The type does not know.
A better version:
type Product = {
id: string
title: string
price: number
}
type ProductRequest =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; products: Array<Product> }
| { status: 'error'; message: string }Now only valid states can exist.
function ProductListView({
request,
}: {
request: ProductRequest
}) {
switch (request.status) {
case 'idle':
return <p>Select a category.</p>
case 'loading':
return <p>Loading products...</p>
case 'success':
return (
<ul>
{request.products.map((product) => (
<li key={product.id}>{product.title}</li>
))}
</ul>
)
case 'error':
return <p>{request.message}</p>
}
}The compiler narrows the type automatically.
In the success branch, request.products exists.
In the error branch, request.message exists.
This is one of the most important TypeScript refactoring patterns.
6. Add Exhaustiveness Checking
A strong candidate should also mention exhaustive checks.
Suppose we extend the union:
type ProductRequest =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; products: Array<Product> }
| { status: 'error'; message: string }
| { status: 'empty' }If we forget to handle empty, the UI may silently break.
Add a helper:
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}Use it in the switch:
function ProductListView({
request,
}: {
request: ProductRequest
}) {
switch (request.status) {
case 'idle':
return <p>Select a category.</p>
case 'loading':
return <p>Loading products...</p>
case 'success':
return (
<ul>
{request.products.map((product) => (
<li key={product.id}>{product.title}</li>
))}
</ul>
)
case 'error':
return <p>{request.message}</p>
default:
return assertNever(request)
}
}Now TypeScript complains if a new state is added but not handled.
That is the kind of answer interviewers like because it shows practical understanding, not just syntax knowledge.
7. Use as const satisfies for Safer Constants
Interview question:
“How can you define constants so that values remain narrow but are still checked against an expected type?”
This is a subtle but powerful TypeScript feature.
Suppose we have roles:
type AccessRole = 'viewer' | 'editor' | 'owner'Bad version:
const accessRoles: ReadonlyArray<AccessRole> = [
'viewer',
'editor',
]This checks the values, but the array type becomes broader than necessary:
ReadonlyArray<AccessRole>Another bad version:
const accessRoles = ['viewer', 'edtor'] as constNow the values are narrow and readonly, but TypeScript does not know that they are supposed to satisfy AccessRole. The typo may remain hidden until later.
Best version:
const accessRoles = [
'viewer',
'editor',
'owner',
] as const satisfies ReadonlyArray<AccessRole>This gives you both benefits:
as constkeeps literal valuessatisfieschecks compatibilitythe values remain readonly
typos are caught
Example with route permissions:
type AccessRole = 'viewer' | 'editor' | 'owner'
const routePermissions = {
dashboard: ['viewer', 'editor', 'owner'],
settings: ['owner'],
articles: ['editor', 'owner'],
} as const satisfies Record<string, ReadonlyArray<AccessRole>>If someone writes:
const routePermissions = {
dashboard: ['viewer', 'admin'],
} as const satisfies Record<string, ReadonlyArray<AccessRole>>TypeScript catches it:
Type '"admin"' is not assignable to type 'AccessRole'.This pattern is excellent for:
feature flags
app routes
navigation config
role lists
allowed statuses
design tokens
translation keys
8. Use Template Literal Types for Safer Strings
Interview question:
“How can TypeScript protect us from typos in string paths, translation keys, or API endpoints?”
Bad version:
const endpoint = '/api/usrers'The compiler says nothing.
A safer model:
type ApiResource = 'users' | 'posts' | 'comments'
type ApiEndpoint = `/api/${ApiResource}`
const usersEndpoint: ApiEndpoint = '/api/users'
const postsEndpoint: ApiEndpoint = '/api/posts'
// Error
const brokenEndpoint: ApiEndpoint = '/api/usrers'Template literal types are extremely useful when the string has a predictable structure.
Example with localization keys:
type LocalePage = 'home' | 'checkout' | 'profile'
type LocaleField = 'title' | 'subtitle' | 'description'
type TranslationKey = `${LocalePage}.${LocaleField}`
const validKey: TranslationKey = 'checkout.title'
// Error
const invalidKey: TranslationKey = 'checkout.heading'Example with CSS utility names:
type ColorName = 'blue' | 'red' | 'green'
type Shade = '100' | '500' | '900'
type ColorToken = `${ColorName}-${Shade}`
const primaryColor: ColorToken = 'blue-500'
// Error
const wrongColor: ColorToken = 'purple-700'Example with event names:
type EntityName = 'user' | 'invoice' | 'subscription'
type EventAction = 'created' | 'updated' | 'deleted'
type DomainEvent = `${EntityName}.${EventAction}`
const eventName: DomainEvent = 'invoice.created'
// Error
const invalidEventName: DomainEvent = 'invoice.paid'The point is not to type every string in the application. The point is to type strings that represent important contracts.
9. Prefer unknown Over any
Interview question:
“What is the difference between
anyandunknown, and why isanydangerous?”
any disables TypeScript.
async function loadProfile() {
const response: any = await fetch('/api/profile').then((res) =>
res.json(),
)
return response.user.fullName.toUpperCase()
}This compiles even if:
responseisnulluserdoes not existfullNameis a numberthe API returns an error object
unknown is safer:
async function loadProfile() {
const response: unknown = await fetch('/api/profile').then((res) =>
res.json(),
)
if (isProfileResponse(response)) {
return response.user.fullName.toUpperCase()
}
throw new Error('Invalid profile response')
}Define a type guard:
type ProfileResponse = {
user: {
id: string
fullName: string
}
}
function isProfileResponse(value: unknown): value is ProfileResponse {
if (typeof value !== 'object' || value === null) {
return false
}
if (!('user' in value)) {
return false
}
const candidate = value as {
user?: {
id?: unknown
fullName?: unknown
}
}
return (
typeof candidate.user?.id === 'string' &&
typeof candidate.user?.fullName === 'string'
)
}Now the code must prove the shape before using it.
A more realistic production approach uses a schema validator:
import { z } from 'zod'
const ProfileResponseSchema = z.object({
user: z.object({
id: z.string(),
fullName: z.string(),
}),
})
type ProfileResponse = z.infer<typeof ProfileResponseSchema>
async function loadProfile(): Promise<ProfileResponse> {
const response: unknown = await fetch('/api/profile').then((res) =>
res.json(),
)
return ProfileResponseSchema.parse(response)
}Strong answer:
anysays “trust me.”unknownsays “check me first.” In application boundaries such as APIs, localStorage, messages, or user input,unknownis usually the safer default.
10. Be Careful with Type Assertions
Interview question:
“When is it acceptable to use
as SomeTypeor the non-null assertion operator?”
Type assertions are sometimes necessary, but they are also easy to abuse.
Bad version:
type ViewerProfile = {
displayName: string
avatarUrl: string | null
}
const profile = {
displayName: 'Nina',
} as ViewerProfile
renderAvatar(profile.avatarUrl!)This is unsafe.
The object does not actually contain avatarUrl, but the assertion tells TypeScript to pretend it does.
A safer version:
type ViewerProfile = {
displayName: string
avatarUrl: string | null
}
const profile: ViewerProfile = {
displayName: 'Nina',
avatarUrl: null,
}
if (profile.avatarUrl !== null) {
renderAvatar(profile.avatarUrl)
}If the value comes from an unknown source, validate it:
function hasAvatarUrl(value: unknown): value is ViewerProfile {
if (typeof value !== 'object' || value === null) {
return false
}
const candidate = value as {
displayName?: unknown
avatarUrl?: unknown
}
return (
typeof candidate.displayName === 'string' &&
(typeof candidate.avatarUrl === 'string' ||
candidate.avatarUrl === null)
)
}Then use it:
const rawProfile: unknown = await getProfileFromStorage()
if (hasAvatarUrl(rawProfile)) {
if (rawProfile.avatarUrl !== null) {
renderAvatar(rawProfile.avatarUrl)
}
}When are assertions acceptable?
when working around incorrect third-party types
when narrowing DOM APIs after a runtime check
when migrating legacy code gradually
when TypeScript cannot express a runtime guarantee
when the assertion is small and documented
Example:
const button = document.querySelector('[data-save-button]')
if (!(button instanceof HTMLButtonElement)) {
throw new Error('Save button was not found')
}
button.disabled = trueThis is better than:
const button = document.querySelector(
'[data-save-button]',
) as HTMLButtonElement
button.disabled = trueThe first version checks reality.
The second version only silences the compiler.
11. Prefer @ts-expect-error Over @ts-ignore
Interview question:
“How should you suppress a TypeScript error when you really cannot avoid it?”
Bad version:
// @ts-ignore
const result = callLegacyGateway('invoice-123')The problem with @ts-ignore is that it stays silent forever. Even if the underlying type issue is fixed, the comment remains.
Better:
// @ts-expect-error: legacy gateway still expects a numeric ID.
// Remove this after the billing API migration.
const result = callLegacyGateway('invoice-123')@ts-expect-error behaves differently.
If TypeScript no longer finds an error on the next line, it reports that the directive is unnecessary.
That makes technical debt visible.
Bad:
// @ts-ignore
doSomethingUnsafe()Better:
// @ts-expect-error: third-party package has incorrect types for this overload.
// Runtime behavior is covered by integration tests.
doSomethingUnsafe()The best interview answer:
Suppressing TypeScript errors should be rare, local, and explained. Use
@ts-expect-errorbecause it fails when the error disappears. Avoid@ts-ignoreunless there is no alternative.
12. type vs interface
Interview question:
“Should you use
typeorinterface?”
Both are useful, but they are not identical.
A type alias can represent unions:
type UploadStatus = 'idle' | 'uploading' | 'done' | 'failed'An interface cannot do this:
interface UploadStatus = 'idle' | 'uploading'
// Invalid syntaxA type alias is also good for function types:
type PriceFormatter = (amount: number, currency: string) => stringAnd object shapes:
type ProductSummary = {
id: string
title: string
price: number
}An interface is useful when you specifically want extension or declaration merging:
interface Window {
analytics?: {
track(eventName: string): void
}
}Or when modeling public object contracts that may be extended:
interface Logger {
info(message: string): void
error(message: string, error?: unknown): void
}
interface JsonLogger extends Logger {
flush(): Promise<void>
}A practical rule:
Use
typeby default, especially for unions, function types, and discriminated unions. Useinterfacewhen you need declaration merging or extension-heavy public contracts.
The original source also recommends type as the default choice and keeps interface for cases where extension or global declaration merging is useful.
13. T[] vs Array<T>
Interview question:
“Is there a difference between
T[]andArray<T>? Which style do you prefer?”
Both represent arrays.
const tags: string[] = ['typescript', 'react']
const labels: Array<string> = ['frontend', 'web']For simple cases, T[] is concise and common.
But Array<T> can be easier to read for nested or readonly types:
const matrix: Array<Array<number>> = [
[1, 2],
[3, 4],
]Readonly arrays are also clearer for some teams:
function calculateAverage(values: ReadonlyArray<number>): number {
if (values.length === 0) {
return 0
}
const total = values.reduce((sum, value) => sum + value, 0)
return total / values.length
}Compare:
function calculateAverage(values: readonly number[]): number {
// ...
}Both are valid.
A strong interview answer should avoid pretending there is one universal rule.
Better answer:
Both styles work. Pick one convention per project. I usually prefer
Array<T>andReadonlyArray<T>when nested generics or readonly input parameters are involved because the intent is more visible.
14. Use import type for Type-Only Imports
Interview question:
“Why should we sometimes write
import type?”
Bad version:
import { ProductCardProps } from './ProductCard.types'If ProductCardProps is only a type, use:
import type { ProductCardProps } from './ProductCard.types'This clearly tells TypeScript and the bundler that the import does not exist at runtime.
Example:
import type { UserProfile } from '@/modules/users/types'
import { formatDisplayName } from '@/modules/users/utils'
function getProfileLabel(profile: UserProfile): string {
return formatDisplayName(profile)
}Benefits:
separates runtime code from compile-time types
avoids accidental side effects from type-only modules
improves clarity
helps with strict compiler options
can reduce bundling surprises
This matters more in modern TypeScript projects using:
isolatedModulesbundlers
ESM
server/client boundaries
React Server Components
monorepos
A clean import section often looks like this:
import { useMemo } from 'react'
import type { Invoice } from '@/modules/billing/types'
import { formatCurrency } from '@/shared/money'Runtime imports and type imports have different jobs. Keeping them separate makes the file easier to reason about.
15. Replace Long Argument Lists with an Options Object
Interview question:
“How can you refactor a function with too many positional arguments?”
Bad version:
createReport('pdf', true, 30, 100, null, false, 5000)Nobody wants to read this.
What does true mean?
What does 30 mean?
What does null mean?
What happens if two arguments are swapped?
A better version:
createReport({
format: 'pdf',
includeCharts: true,
minRows: 30,
maxRows: 100,
fallbackTitle: null,
sendEmail: false,
timeoutMs: 5000,
})Now the call site explains itself.
Define a type:
type ReportFormat = 'pdf' | 'csv' | 'html'
type CreateReportOptions = {
format: ReportFormat
includeCharts: boolean
minRows: number
maxRows: number
fallbackTitle: string | null
sendEmail: boolean
timeoutMs: number
}
function createReport(options: CreateReportOptions): void {
// implementation
}You can also use satisfies at the call site:
const monthlyReportOptions = {
format: 'pdf',
includeCharts: true,
minRows: 30,
maxRows: 100,
fallbackTitle: null,
sendEmail: false,
timeoutMs: 5000,
} satisfies CreateReportOptions
createReport(monthlyReportOptions)For more flexible APIs, split required and optional values:
type ExportFormat = 'json' | 'csv' | 'xlsx'
type ExportOrdersOptions = {
format: ExportFormat
dateFrom: Date
dateTo: Date
includeRefunded?: boolean
timezone?: string
}
function exportOrders({
format,
dateFrom,
dateTo,
includeRefunded = false,
timezone = 'UTC',
}: ExportOrdersOptions): Promise<Blob> {
// implementation
return Promise.resolve(new Blob())
}This is easier to extend without breaking existing calls.
16. Be Intentional with Return Types
Interview question:
“Should every function have an explicit return type?”
Not always.
For small internal helpers, inference is fine:
function formatFullName(person: {
firstName: string
lastName: string
}) {
return `${person.firstName} ${person.lastName}`
}The return type is obvious.
But for public APIs, hooks, service functions, and exported utilities, explicit return types are valuable.
Bad:
export function useCurrentUser() {
return {
user: null,
isLoading: false,
reload: async () => {},
}
}Better:
type CurrentUser = {
id: string
email: string
displayName: string
}
type UseCurrentUserResult = {
user: CurrentUser | null
isLoading: boolean
reload: () => Promise<void>
}
export function useCurrentUser(): UseCurrentUserResult {
return {
user: null,
isLoading: false,
reload: async () => {
// fetch again
},
}
}Why?
Because exported return types become contracts.
Without an explicit return type, someone might accidentally change the function:
export function useCurrentUser(): UseCurrentUserResult {
return {
user: null,
loading: false,
reload: async () => {},
}
}TypeScript catches the accidental rename from isLoading to loading.
For async functions, return types are also useful:
type CreateInvoiceInput = {
customerId: string
amount: number
}
type CreateInvoiceResult = {
invoiceId: string
paymentUrl: string
}
export async function createInvoice(
input: CreateInvoiceInput,
): Promise<CreateInvoiceResult> {
const response = await fetch('/api/invoices', {
method: 'POST',
body: JSON.stringify(input),
})
if (!response.ok) {
throw new Error('Failed to create invoice')
}
return response.json() as Promise<CreateInvoiceResult>
}The rule:
Internal simple helpers can rely on inference. Public functions should usually declare return types because they define contracts.
17. Avoid Multiple Boolean Flags for State
Interview question:
“Why is one status field often better than several booleans?”
Bad:
const isPending = true
const isProcessing = false
const isCompleted = false
const isCancelled = falseThis seems harmless at first. But what about this?
const isPending = true
const isProcessing = true
const isCompleted = false
const isCancelled = falseNow the state is invalid.
A better version:
type CheckoutState =
| 'pending'
| 'processing'
| 'completed'
| 'cancelled'
const checkoutState: CheckoutState = 'pending'Only one state can exist at a time.
For more complex cases, use a discriminated union:
type CheckoutProcess =
| { status: 'pending' }
| { status: 'processing'; startedAt: Date }
| { status: 'completed'; receiptId: string }
| { status: 'cancelled'; reason: string }Now each state carries the data it needs:
function getCheckoutMessage(process: CheckoutProcess): string {
switch (process.status) {
case 'pending':
return 'Waiting for payment.'
case 'processing':
return `Processing since ${process.startedAt.toISOString()}.`
case 'completed':
return `Receipt: ${process.receiptId}`
case 'cancelled':
return `Cancelled: ${process.reason}`
}
}This is safer than spreading related state across unrelated booleans.
18. Understand null vs undefined
Interview question:
“What is the difference between
nullandundefined, and when should each be used?”
A practical rule:
undefinedmeans a value is missing or was not providednullmeans the value exists conceptually, but is intentionally empty
Example:
type UserProfile = {
id: string
displayName: string
avatarUrl: string | null
bio?: string
}Here:
avatarUrl: string | nullmeans every profile has an avatar field, but the user may not have uploaded an avatar.
Meanwhile:
bio?: stringmeans the field may be missing entirely.
Usage:
function renderProfile(profile: UserProfile) {
const avatar = profile.avatarUrl ?? '/default-avatar.png'
const bio = profile.bio ?? 'No bio yet.'
return {
avatar,
bio,
}
}Bad version:
type UserProfile = {
avatarUrl?: string | null
}This allows too many states:
missing
undefined
null
string
Sometimes that is necessary, but often it is accidental.
A good answer:
Use
nullfor intentional empty values andundefinedfor omitted values. Avoid mixing both unless the domain really needs both.
19. Use Better Naming to Make Types Obvious
Interview question:
“How do naming conventions improve refactoring?”
Names are part of the type system in practice. The compiler cannot understand intent if the code hides it behind vague words.
Bad:
const data = getData()
const flag = true
const item = list[0]
function handle(value: unknown) {}Better:
const customerProfile = getCustomerProfile()
const hasBillingAddress = true
const firstInvoice = invoices[0]
function handleInvoiceSubmit(input: InvoiceFormInput) {
// ...
}Common conventions:
const isVisible = true
const hasPermission = false
const canEditProfile = true
const shouldRetryRequest = falseBoolean names should sound like yes/no questions.
Good function names:
function formatInvoiceTotal(amount: number): string {
return `$${amount.toFixed(2)}`
}
function calculateCartSubtotal(items: ReadonlyArray<CartItem>): number {
return items.reduce((total, item) => {
return total + item.unitPrice * item.quantity
}, 0)
}
function createCheckoutSession(customerId: string): Promise<string> {
return Promise.resolve(`session-${customerId}`)
}Generic names should be descriptive:
type ApiResponse<TData> = {
data: TData
receivedAt: string
}
type PaginatedResult<TEntity> = {
items: Array<TEntity>
page: number
totalPages: number
}Avoid generic names like this:
type Box<T> = {
value: T
}Unless the abstraction is genuinely generic and obvious.
Good naming makes code easier to refactor because the next developer can understand the domain before touching the implementation.
20. Write Comments That Explain Why, Not What
Interview question:
“Should every line of code have a comment?”
No.
Bad comment:
// Add one to retry count
retryCount += 1The code already says that.
Better comment:
// The provider may return 429 for short traffic bursts.
// A single retry avoids failing checkout for temporary rate limits.
retryCount += 1Good comments explain context that the code cannot show.
Bad:
// Get user
const user = await getUser(userId)Better:
// We intentionally read from the primary database here.
// The replica can lag by several seconds after signup.
const user = await getUserFromPrimaryDatabase(userId)Bad:
// Check if order is paid
if (order.status === 'paid') {
sendReceipt(order)
}Better:
// Some payment providers send duplicate webhooks.
// Sending the receipt only after the local state is paid prevents duplicates.
if (order.status === 'paid') {
sendReceipt(order)
}Use TSDoc for public utilities:
/**
* Converts a price from minor units to a localized currency string.
*
* Example:
* formatMoney(1299, 'USD') -> "$12.99"
*/
export function formatMoney(
amountInCents: number,
currency: string,
): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amountInCents / 100)
}A strong answer:
Comments should not narrate obvious code. They should preserve decisions, constraints, tradeoffs, and business context.
21. Organize Files by Feature, Not by File Type
Interview question:
“How would you structure a project so that refactoring is easier?”
A common weak structure:
src/
components/
ProductCard.tsx
PriceBadge.tsx
CheckoutForm.tsx
hooks/
useProduct.ts
useCheckout.ts
utils/
formatPrice.ts
validateCart.ts
api/
products.ts
checkout.tsThis looks clean at first, but it spreads one feature across many folders.
A feature-based structure is often easier to maintain:
src/
modules/
product-page/
index.tsx
api/
fetchProduct.ts
fetchRelatedProducts.ts
components/
ProductCard.tsx
PriceBadge.tsx
ProductGallery.tsx
hooks/
useProductDetails.ts
utils/
formatProductPrice.ts
types.ts
checkout/
index.tsx
api/
createCheckoutSession.ts
components/
CheckoutForm.tsx
PaymentSummary.tsx
hooks/
useCheckoutState.ts
utils/
calculateCartTotal.ts
types.tsBenefits:
related code stays together
deleting a feature is easier
moving a feature is easier
imports are easier to reason about
new developers find code faster
Good local import:
import { ProductCard } from './components/ProductCard'
import { useProductDetails } from './hooks/useProductDetails'Good cross-module import:
import { formatMoney } from '@/shared/money'Avoid deep imports across features:
import { calculateCartTotal } from '@/modules/checkout/utils/calculateCartTotal'That can be fine sometimes, but if many modules need it, move it to shared code:
src/
shared/
money/
dates/
validation/
http/The principle:
Group code by feature when it changes together. Extract shared code only when it is truly shared.
22. Refactor Runtime Validation Boundaries
A strong TypeScript candidate should understand that TypeScript does not validate runtime data.
This compiles:
type Article = {
id: string
title: string
publishedAt: string
}
async function fetchArticle(id: string): Promise<Article> {
const response = await fetch(`/api/articles/${id}`)
return response.json()
}But the API can return anything.
Better:
import { z } from 'zod'
const ArticleSchema = z.object({
id: z.string(),
title: z.string(),
publishedAt: z.string(),
})
type Article = z.infer<typeof ArticleSchema>
async function fetchArticle(id: string): Promise<Article> {
const response = await fetch(`/api/articles/${id}`)
if (!response.ok) {
throw new Error(`Failed to fetch article ${id}`)
}
const payload: unknown = await response.json()
return ArticleSchema.parse(payload)
}This combines TypeScript with runtime validation.
Good interview explanation:
TypeScript checks code at compile time. It does not guarantee that external data matches our types. API responses, localStorage, URL params, and third-party messages should be treated as unknown until validated.
This is one of the biggest differences between beginner and senior TypeScript usage.
23. Make Invalid States Impossible
A recurring theme in TypeScript refactoring is this:
Do not merely document valid states. Encode them.
Bad:
type PasswordResetForm = {
email?: string
token?: string
newPassword?: string
step: 'email' | 'password' | 'done'
}This allows invalid combinations:
const form: PasswordResetForm = {
step: 'done',
}Better:
type PasswordResetForm =
| {
step: 'email'
email: string
}
| {
step: 'password'
email: string
token: string
newPassword: string
}
| {
step: 'done'
email: string
}Now the data matches the workflow.
function getResetButtonLabel(form: PasswordResetForm): string {
switch (form.step) {
case 'email':
return 'Send reset link'
case 'password':
return 'Update password'
case 'done':
return 'Back to login'
}
}Invalid combinations become impossible to represent.
This is one of the strongest answers in a TypeScript interview:
The best refactor is not the one that adds more checks everywhere. The best refactor changes the type so the invalid case cannot exist.
24. Use Utility Types Carefully
Utility types are powerful, but they can also hide poor modeling.
Example:
type User = {
id: string
email: string
displayName: string
role: 'user' | 'admin'
}Using Partial<User> everywhere is a smell:
function updateUser(user: Partial<User>) {
// ...
}This allows:
updateUser({})
updateUser({ id: 'u1' })
updateUser({ role: 'admin' })Maybe that is not what the application wants.
Better:
type UpdateUserInput = {
id: string
displayName?: string
role?: 'user' | 'admin'
}Now id is required, but editable fields are optional.
Use utility types when they match intent:
type PublicUser = Pick<User, 'id' | 'displayName'>
type UserWithoutRole = Omit<User, 'role'>
type UserDraft = Partial<Pick<User, 'email' | 'displayName'>>Avoid using utility types as shortcuts when a named domain type would be clearer.
Better:
type CreateUserInput = {
email: string
displayName: string
}Instead of:
type CreateUserInput = Pick<User, 'email' | 'displayName'>Both are valid, but the explicit version may be clearer if the creation contract is not supposed to automatically change when User changes.
25. Refactor React Props with Discriminated Unions
This is a practical frontend example.
Bad:
type ButtonProps = {
label: string
href?: string
onClick?: () => void
disabled?: boolean
}This allows invalid states:
<Button label="Save" />
<Button label="Save" href="/settings" onClick={() => {}} />Is it a link or a button?
Better:
type LinkButtonProps = {
kind: 'link'
label: string
href: string
}
type ActionButtonProps = {
kind: 'action'
label: string
onClick: () => void
disabled?: boolean
}
type ButtonProps = LinkButtonProps | ActionButtonProps
function Button(props: ButtonProps) {
if (props.kind === 'link') {
return <a href={props.href}>{props.label}</a>
}
return (
<button onClick={props.onClick} disabled={props.disabled}>
{props.label}
</button>
)
}Now the component API is clearer.
Usage:
<Button kind="link" label="Read docs" href="/docs" />
<Button
kind="action"
label="Save changes"
onClick={saveSettings}
disabled={isSaving}
/>Invalid usage fails:
<Button kind="link" label="Save" onClick={saveSettings} />This pattern is excellent for component props where different variants require different fields.
26. Refactor API Results Instead of Throwing Strings Everywhere
Bad:
async function saveSettings(input: SettingsInput) {
try {
await api.saveSettings(input)
return true
} catch {
return false
}
}This loses error details.
A more explicit version:
type SaveSettingsResult =
| { status: 'success' }
| { status: 'validation-error'; field: string; message: string }
| { status: 'network-error'; message: string }
async function saveSettings(
input: SettingsInput,
): Promise<SaveSettingsResult> {
const response = await fetch('/api/settings', {
method: 'POST',
body: JSON.stringify(input),
})
if (response.status === 400) {
const payload = await response.json()
return {
status: 'validation-error',
field: String(payload.field),
message: String(payload.message),
}
}
if (!response.ok) {
return {
status: 'network-error',
message: 'Unable to save settings.',
}
}
return { status: 'success' }
}Usage:
const result = await saveSettings(input)
switch (result.status) {
case 'success':
showToast('Settings saved.')
break
case 'validation-error':
showFieldError(result.field, result.message)
break
case 'network-error':
showToast(result.message)
break
}This is much easier to handle than random thrown errors.
27. Use Branded Types for IDs When Necessary
Many applications use strings for all IDs:
type UserId = string
type ProductId = string
function loadUser(id: UserId) {
// ...
}
const productId: ProductId = 'p1'
loadUser(productId)This compiles because both are just strings.
For critical domains, use branded types:
type Brand<TValue, TBrand extends string> = TValue & {
readonly __brand: TBrand
}
type UserId = Brand<string, 'UserId'>
type ProductId = Brand<string, 'ProductId'>
function createUserId(value: string): UserId {
return value as UserId
}
function createProductId(value: string): ProductId {
return value as ProductId
}
function loadUser(id: UserId) {
// ...
}
const productId = createProductId('p1')
// Error
loadUser(productId)This is not needed everywhere. But it can be useful for:
user IDs
organization IDs
payment IDs
database primary keys
security-sensitive identifiers
A good interview answer mentions tradeoffs:
Branded types improve safety for important identifiers, but they add complexity. I would use them selectively, not for every string in the codebase.
28. Refactor Generic Types for Readability
Bad:
type Response<T> = {
data: T
error?: string
}This is not terrible, but the generic name is vague.
Better:
type ApiResult<TPayload> =
| {
status: 'success'
payload: TPayload
}
| {
status: 'error'
message: string
}Usage:
type ProductListResult = ApiResult<Array<Product>>
function renderProducts(result: ProductListResult) {
if (result.status === 'error') {
return result.message
}
return result.payload.map((product) => product.title).join(', ')
}Generic names should describe their role:
type PaginatedResponse<TEntity> = {
items: Array<TEntity>
page: number
totalPages: number
}
type EventHandler<TEvent> = (event: TEvent) => void
type Repository<TEntity, TId> = {
findById(id: TId): Promise<TEntity | null>
save(entity: TEntity): Promise<void>
}T is fine for tiny utilities. For application code, descriptive generic names are usually better.
29. Practical Interview Checklist
When asked to refactor TypeScript code, do not jump straight into syntax.
Walk through the code like this:
What invalid states can this type represent?
Are any values typed too broadly?
Are optional fields hiding different variants?
Are booleans representing a state machine?
Can
anybe replaced withunknown?Should external data be validated at runtime?
Is mutation allowed accidentally?
Are function parameters readable?
Does the public API have explicit return types?
Are constants checked with
satisfies?Are type assertions hiding real bugs?
Would a discriminated union make this clearer?
This is how you show senior-level thinking.
30. Example Interview Refactor from Start to Finish
Imagine the interviewer gives this code:
type Order = {
id?: string
userId?: string
status?: string
items?: any[]
error?: string
}
async function submitOrder(order: Order) {
if (order.status === 'ready') {
const result: any = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(order),
}).then((res) => res.json())
return result.id
}
return null
}Problems:
every field is optional
statusis any stringitemsusesany[]API response uses
anyreturn type is inferred vaguely
invalid order states are possible
Refactor:
type OrderItem = {
productId: string
quantity: number
unitPrice: number
}
type DraftOrder = {
status: 'draft'
items: ReadonlyArray<OrderItem>
}
type ReadyOrder = {
status: 'ready'
customerId: string
items: ReadonlyArray<OrderItem>
}
type FailedOrder = {
status: 'failed'
message: string
}
type Order = DraftOrder | ReadyOrder | FailedOrder
type SubmitOrderResponse = {
id: string
}
function isSubmitOrderResponse(
value: unknown,
): value is SubmitOrderResponse {
if (typeof value !== 'object' || value === null) {
return false
}
return (
'id' in value &&
typeof (value as { id?: unknown }).id === 'string'
)
}
async function submitReadyOrder(
order: ReadyOrder,
): Promise<string> {
const response = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(order),
})
if (!response.ok) {
throw new Error('Failed to submit order')
}
const payload: unknown = await response.json()
if (!isSubmitOrderResponse(payload)) {
throw new Error('Invalid submit order response')
}
return payload.id
}Usage:
function submitOrder(order: Order): Promise<string | null> {
switch (order.status) {
case 'ready':
return submitReadyOrder(order)
case 'draft':
case 'failed':
return Promise.resolve(null)
}
}This refactor improves:
domain modeling
runtime safety
API boundaries
return types
readability
maintainability
That is the kind of refactoring interviewers want to see.
Final Thoughts
TypeScript refactoring is not about adding more types everywhere.
It is about making the code more honest.
Good TypeScript code describes what the application actually allows. It prevents impossible states, narrows dangerous values, protects external boundaries, and makes refactoring safer.
The strongest interview answers usually follow the same pattern:
identify the weakness
explain why it is dangerous
refactor the type model
show how the compiler now helps
mention tradeoffs
A candidate who can do that is not just “good at TypeScript syntax.”
They understand how to use TypeScript as a design tool.
That is what modern TypeScript interviews increasingly test.


