TypeScript is now the #1 language on GitHub with 2.6M contributors. Master all 50 interview questions - generics, conditional types, React integration, and real-world patterns asked in 2026.
TypeScript became the #1 language on GitHub in 2025, with 48.8% of professional developers using it daily. This guide covers all 50 interview questions junior to senior developers face - fundamentals, generics, advanced type system, React integration, and production patterns - each with a concise answer and a runnable code snippet.
TypeScript became the #1 language on GitHub in August 2025 - 2,636,006 monthly active contributors, up 66.6% year-over-year - overtaking Python and JavaScript for the first time (GitHub Octoverse 2025). Among professional developers, 48.8% now use it daily (Stack Overflow Developer Survey 2025), and 40% write exclusively in TypeScript - up from just 28% in 2022 (State of JS 2025). TypeScript isn't a career differentiator anymore. It's the baseline.
The challenge isn't finding TypeScript questions. It's finding a single resource that covers 2026's hiring bar - TypeScript 5.x features, advanced type patterns, React integration, and real-world scenarios - not 2021-era content. This guide answers all 50 questions you're likely to face, organized by difficulty, each with a clean code snippet and an interview tip where it counts.
Key Takeaways
TypeScript is the #1 language on GitHub (2025) - 48.8% of professional devs use it daily (Stack Overflow 2025)
40% of developers now write exclusively in TypeScript, up from 28% in 2022 (State of JS 2025)
Advanced generics, conditional types, and
satisfiesare the 2026 interview differentiatorsThis guide covers all 4 interview tiers: fundamentals, intermediate, advanced, and React integration
frontend interview prep roadmap → comprehensive guide to preparing for frontend developer interviews
TypeScript is used by 43.6% of all developers and 48.8% of professional developers, making it one of the fastest-growing languages in the Stack Overflow 2025 survey (Stack Overflow, 2025). Fundamentals questions test whether you understand TypeScript's core model - not just its syntax sugar over JavaScript.
TypeScript is a statically typed superset of JavaScript that compiles to plain JavaScript. Every valid JavaScript file is also a valid TypeScript file. TypeScript adds a type layer that catches errors at compile time, not at runtime - which is why 94% of errors generated by LLMs in code are type-related and TypeScript catches them automatically (GitHub Octoverse 2025).
// JavaScript - error discovered at runtime
function add(a, b) {
return a + b;
}
add("2", 3); // "23" - silent bug
// TypeScript - error caught at compile time
function add(a: number, b: number): number {
return a + b;
}
add("2", 3); // ❌ Argument of type 'string' is not assignable to parameter of type 'number'Interview tip: Mention that TypeScript's value scales with team size. Solo devs can live without it; large teams can't ship safely without it.
TypeScript mirrors JavaScript's primitives - string, number, boolean, null, undefined, symbol, and bigint - and adds void, never, unknown, and any as TypeScript-specific types.
let name: string = "Abhijeet";
let age: number = 25;
let isActive: boolean = true;
let nothing: null = null;
let notAssigned: undefined = undefined;
let unique: symbol = Symbol("id");
let big: bigint = 9007199254740991n;Both define object shapes, but they aren't interchangeable. interface is open - it supports declaration merging and extends. type is closed - it can model unions, intersections, tuples, and primitives that interface can't.
// interface: extendable, mergeable
interface User {
name: string;
}
interface User {
age: number; // Declaration merging - valid!
}
// type: more flexible
type ID = string | number; // Union - only type can do this
type Point = { x: number } & { y: number }; // IntersectionInterview tip: In practice, use interface for public API shapes (extendable by consumers) and type for unions, mapped types, and utility compositions.
any turns off type checking entirely - it's an escape hatch that removes all safety. unknown is the type-safe alternative: you can assign anything to unknown, but you must narrow the type before using it.
// any - dangerous
let a: any = "hello";
a.toFixed(2); // No error - crashes at runtime
// unknown - safe
let u: unknown = "hello";
u.toFixed(2); // ❌ Error - must narrow first
if (typeof u === "string") {
console.log(u.toUpperCase()); // ✅ Safe after narrowing
}Interview tip: "Never use any. Use unknown when you don't know the type upfront - like when parsing JSON from an API."
never represents a value that never occurs. It's used for functions that always throw, infinite loops, and exhaustive checks in discriminated unions. If TypeScript infers never, it means a code path is unreachable.
// Function that always throws - return type is never
function fail(message: string): never {
throw new Error(message);
}
// Exhaustive switch - TypeScript ensures all cases are handled
type Shape = "circle" | "square";
function area(shape: Shape): number {
switch (shape) {
case "circle":
return Math.PI;
case "square":
return 1;
default:
const _exhaustive: never = shape; // ❌ Error if a case is missing
return _exhaustive;
}
}A union type (A | B) means a value can be either A or B. An intersection type (A & B) means a value must satisfy both A and B simultaneously.
type StringOrNumber = string | number;
let id: StringOrNumber = "abc";
id = 123; // Also valid
type WithTimestamps = { createdAt: Date; updatedAt: Date };
type User = { name: string; email: string } & WithTimestamps;
const user: User = {
name: "Abhijeet",
email: "a@example.com",
createdAt: new Date(),
updatedAt: new Date(),
};Type narrowing is TypeScript's ability to refine a broad type to a more specific one inside a conditional block. TypeScript understands typeof, instanceof, in, equality checks, and truthiness checks.
function process(value: string | number) {
if (typeof value === "string") {
// TypeScript knows value is string here
console.log(value.toUpperCase());
} else {
// TypeScript knows value is number here
console.log(value.toFixed(2));
}
}as const freezes a value to its most specific literal type. Without it, TypeScript widens string literals to string. With it, each value becomes a read-only literal type - useful for defining constants and discriminated unions.
// Without as const - widened to string[]
const directions = ["north", "south", "east", "west"];
// type: string[]
// With as const - literal tuple
const directions = ["north", "south", "east", "west"] as const;
// type: readonly ["north", "south", "east", "west"]
type Direction = (typeof directions)[number];
// type: "north" | "south" | "east" | "west"Enums let you define a set of named constants. Numeric enums auto-increment; string enums require explicit values. The drawback: numeric enums have reverse mappings that bloat the compiled output, and they can cause subtle bugs when compared numerically.
// Numeric enum (has reverse mapping in compiled JS)
enum Direction {
Up, // 0
Down, // 1
}
// String enum (no reverse mapping - preferred)
enum Status {
Active = "ACTIVE",
Inactive = "INACTIVE",
}
// Modern alternative: const object + union type
const STATUS = { Active: "ACTIVE", Inactive: "INACTIVE" } as const;
type Status = (typeof STATUS)[keyof typeof STATUS]; // "ACTIVE" | "INACTIVE"Interview tip: Many teams prefer as const objects over enums because they produce zero runtime overhead.
A type assertion (as Type) tells the compiler to treat a value as a specific type, bypassing inference. Use it only when you know more than TypeScript does - like working with DOM APIs or third-party responses. Never use it to suppress legitimate errors.
// DOM - TypeScript knows getElementById returns HTMLElement | null
const input = document.getElementById("email") as HTMLInputElement;
input.value = "hello@example.com"; // ✅ Safe - we know it's an input
// Danger zone - this silences an actual bug
const count = "five" as unknown as number;
count.toFixed(2); // 💥 Crashes at runtimeconst prevents variable reassignment. readonly prevents property mutation on an object. They operate at different levels - const is a variable declaration; readonly is a type-level constraint.
const user = { name: "Abhijeet", age: 25 };
user.name = "John"; // ✅ const doesn't protect properties
interface User {
readonly id: number;
name: string;
}
const u: User = { id: 1, name: "Abhijeet" };
u.id = 2; // ❌ Cannot assign to 'id' because it is a read-only propertyTypeScript uses structural typing (duck typing): two types are compatible if they have the same structure, regardless of their name. If it has the right shape, it passes the type check.
interface Point {
x: number;
y: number;
}
function logPoint(p: Point) {
console.log(`${p.x}, ${p.y}`);
}
const coord = { x: 10, y: 20, label: "origin" }; // Extra property is fine
logPoint(coord); // ✅ Works - coord has x and yvoid is the return type of functions that don't return a meaningful value. It's different from undefined - a function typed as () => void can return a value, but TypeScript will ignore it. This is intentional for callback compatibility.
function log(message: string): void {
console.log(message);
// No return statement needed
}
// Callback compatibility - Array.forEach expects (value) => void
const arr = [1, 2, 3];
arr.forEach((n) => n * 2); // ✅ Return value ignored - void is compatibleOptional chaining (?.) lets you safely access deeply nested properties without throwing if any intermediate value is null or undefined. TypeScript narrows the result type to include undefined automatically.
interface User {
address?: {
city?: string;
};
}
const user: User = {};
const city = user.address?.city; // type: string | undefined - no runtime errorstrict: true in tsconfig.json enables a bundle of compiler checks: strictNullChecks, strictFunctionTypes, strictPropertyInitialization, noImplicitAny, noImplicitThis, and more. It's the professional standard.
// tsconfig.json
{
"compilerOptions": {
"strict": true
}
}// Without strict - this compiles fine and crashes at runtime
function greet(user: { name: string } | null) {
console.log(user.name); // ❌ Error with strict - user could be null
}
// With strict - you must handle null
function greet(user: { name: string } | null) {
console.log(user?.name ?? "Guest"); // ✅ Safe
}TypeScript's median US salary is $110,718/year with senior engineers averaging $138,917 (PayScale, March 2026). The intermediate tier is where interview scores diverge - candidates who know generics and utility types from those who only know the basics.
Generics let you write reusable, type-safe functions and classes without hardcoding a specific type. The type parameter is a placeholder filled in at call time.
// Without generics - loses type information
function identity(value: any): any {
return value;
}
// With generics - type-safe and flexible
function identity<T>(value: T): T {
return value;
}
const str = identity("hello"); // type: string
const num = identity(42); // type: numberConstraints (extends) restrict what types can be passed to a generic parameter. Without a constraint, TypeScript doesn't know what properties the type has.
// Unconstrained - can't access .length
function getLength<T>(value: T): number {
return value.length; // ❌ Property 'length' does not exist on type 'T'
}
// Constrained - now TypeScript knows T has a length property
function getLength<T extends { length: number }>(value: T): number {
return value.length; // ✅
}
getLength("hello"); // 5
getLength([1, 2, 3]); // 3TypeScript ships built-in generic utility types that transform existing types:
interface User {
id: number;
name: string;
email: string;
age: number;
}
type PartialUser = Partial<User>; // All fields optional
type RequiredUser = Required<User>; // All fields required
type UserPreview = Pick<User, "id" | "name">; // Only id and name
type UserWithoutAge = Omit<User, "age">; // Everything except age
type ReadonlyUser = Readonly<User>; // All fields readonly
type UserMap = Record<string, User>; // Dictionary of users
type UserIds = Extract<keyof User, "id" | "name">; // "id" | "name"Interview tip: Know at minimum Partial, Required, Pick, Omit, Record, and Readonly. These appear in nearly every production TypeScript codebase.
keyof T produces a union of the string (or symbol) keys of type T. It's the foundation of many generic patterns - especially safe property access.
interface User {
id: number;
name: string;
email: string;
}
type UserKeys = keyof User; // "id" | "name" | "email"
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // T[K] = indexed access type
}
const user: User = { id: 1, name: "Abhijeet", email: "a@example.com" };
const name = getProperty(user, "name"); // type: string ✅
const id = getProperty(user, "id"); // type: number ✅
getProperty(user, "phone"); // ❌ Type errorMapped types transform every property of an existing type according to a rule. They're how utility types like Partial and Readonly are implemented internally.
// How Partial<T> is implemented
type MyPartial<T> = {
[K in keyof T]?: T[K]; // ? makes every property optional
};
// How Readonly<T> is implemented
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
// Custom - make all properties nullable
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};Conditional types (T extends U ? X : Y) let the type system branch based on a condition. They enable expressive type transformations that respond to the shape of their input.
type IsArray<T> = T extends any[] ? "yes" : "no";
type A = IsArray<string[]>; // "yes"
type B = IsArray<string>; // "no"
// Built-in NonNullable uses a conditional type
type NonNullable<T> = T extends null | undefined ? never : T;
type C = NonNullable<string | null | undefined>; // stringinfer lets you capture and name a type within a conditional type, extracting it for use on the right-hand side.
// Extract the return type of any function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function fetchUser(): Promise<{ id: number; name: string }> {
return Promise.resolve({ id: 1, name: "Abhijeet" });
}
type FetchResult = ReturnType<typeof fetchUser>;
// type: Promise<{ id: number; name: string }>
// Unwrap the Promise
type Awaited<T> = T extends Promise<infer R> ? R : T;
type UserData = Awaited<FetchResult>;
// type: { id: number; name: string }Template literal types let you construct new string types by combining literal types - the same syntax as JavaScript template literals, but at the type level.
type EventName = "click" | "focus" | "blur";
type HandlerName = `on${Capitalize<EventName>}`; // "onClick" | "onFocus" | "onBlur"
type Route = "/users" | "/posts" | "/comments";
type ApiRoute = `https://api.example.com${Route}`;
// "https://api.example.com/users" | ...A discriminated union is a union of types that each share a common literal property (the discriminant). TypeScript uses that property to narrow the type in switch/if blocks.
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Shape = Circle | Square;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
}
}Interview tip: This pattern is the TypeScript alternative to inheritance hierarchies. It's more composable and exhaustiveness-checkable.
satisfies validates that a value matches a type without widening the inferred type. Unlike a type annotation, it lets you keep the specific literal type while still checking constraints.
type Colors = "red" | "green" | "blue";
type Palette = Record<Colors, string | [number, number, number]>;
// With annotation - TypeScript widens to string | [number, number, number]
const palette: Palette = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
};
palette.green.toUpperCase(); // ❌ Error - TypeScript thinks it might be an array
// With satisfies - keeps specific types, still validates shape
const palette = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
} satisfies Palette;
palette.green.toUpperCase(); // ✅ TypeScript knows it's a stringBefore TypeScript 5.0, you needed as const at the call site to preserve literal types in generic functions. With const type parameters, the function itself opts into this behavior.
// Before 5.0 - caller must remember as const
function inferTuple<T extends readonly unknown[]>(value: T) {
return value;
}
const t1 = inferTuple(["a", "b"]); // type: string[] - widened
// TypeScript 5.0+ - const modifier on type parameter
function inferTuple<const T extends readonly unknown[]>(value: T) {
return value;
}
const t2 = inferTuple(["a", "b"]); // type: readonly ["a", "b"] - preservedThese utility types extract the return type and parameter types from any function type - useful for building type-safe wrappers and middleware.
function createUser(name: string, age: number): { id: number; name: string } {
return { id: 1, name };
}
type CreateUserReturn = ReturnType<typeof createUser>;
// type: { id: number; name: string }
type CreateUserParams = Parameters<typeof createUser>;
// type: [name: string, age: number]
// Practical use - type-safe wrapper
function callWithLog<T extends (...args: any[]) => any>(
fn: T,
...args: Parameters<T>
): ReturnType<T> {
console.log("Calling with:", args);
return fn(...args);
}Awaited recursively unwraps Promise types, even when Promises are nested. It was added in TypeScript 4.5 to correctly type async results.
type A = Awaited<Promise<string>>; // string
type B = Awaited<Promise<Promise<number>>>; // number
type C = Awaited<string>; // string (non-Promise passthrough)
async function fetchUser(): Promise<{ id: number }> {
return { id: 1 };
}
type User = Awaited<ReturnType<typeof fetchUser>>; // { id: number }Index signatures describe types for objects with dynamic keys, where you don't know the exact property names upfront.
interface StringMap {
[key: string]: string;
}
const headers: StringMap = {
"Content-Type": "application/json",
Authorization: "Bearer token",
};
// More restrictive - only specific string values
interface Config {
[key: string]: string | number | boolean;
}Interview tip: When noUncheckedIndexedAccess is enabled, index signature access returns T | undefined instead of T - safer but requires null checks.
A type guard is a function that narrows a type at runtime. The is keyword in the return type tells TypeScript: "if this function returns true, the parameter is the specified type."
interface Cat {
meow(): void;
}
interface Dog {
bark(): void;
}
// Custom type guard
function isCat(animal: Cat | Dog): animal is Cat {
return (animal as Cat).meow !== undefined;
}
function makeNoise(animal: Cat | Dog) {
if (isCat(animal)) {
animal.meow(); // TypeScript knows it's a Cat
} else {
animal.bark(); // TypeScript knows it's a Dog
}
}Declaration merging lets you extend an existing interface across multiple declarations. TypeScript merges them into one type. This is commonly used to augment third-party types.
// Third-party library defines:
interface Request {
method: string;
url: string;
}
// Your app extends it via merging
interface Request {
user?: { id: string; role: string };
}
// Now Request has all three properties
function handler(req: Request) {
console.log(req.user?.id);
}Overloads let you define multiple call signatures for a function, each with different parameter/return types. TypeScript picks the right signature based on what you pass.
function format(value: string): string;
function format(value: number, decimals: number): string;
function format(value: string | number, decimals?: number): string {
if (typeof value === "string") return value.trim();
return value.toFixed(decimals ?? 2);
}
format(" hello "); // ✅ string overload
format(3.14159, 2); // ✅ number overload
format(3.14159); // ❌ Error - no matching overloadModule augmentation extends a module's types from outside the module - useful when a library's types are missing properties you've added at runtime.
// Augmenting Express's Request type
import "express";
declare module "express" {
interface Request {
user?: { id: string; email: string };
}
}
// Now req.user is typed in all route handlers
app.get("/me", (req, res) => {
res.json(req.user); // ✅ type: { id: string; email: string } | undefined
});Extract keeps only the members of T that are assignable to U. Exclude removes them. Both use conditional types internally.
type A = "a" | "b" | "c" | "d";
type B = "b" | "d";
type OnlyInBoth = Extract<A, B>; // "b" | "d"
type OnlyInA = Exclude<A, B>; // "a" | "c"
// Practical use - filter a union to specific subtypes
type Events = MouseEvent | KeyboardEvent | FocusEvent;
type MouseOnly = Extract<Events, MouseEvent>; // MouseEventVariadic tuple types (TypeScript 4.0+) let you spread generic tuple types, enabling type-safe wrappers around variadic functions like concat and middleware chains.
type Concat<T extends unknown[], U extends unknown[]> = [...T, ...U];
type Result = Concat<[string, number], [boolean, Date]>;
// type: [string, number, boolean, Date]
// Type-safe function chaining
function concat<T extends unknown[], U extends unknown[]>(
a: T,
b: U,
): [...T, ...U] {
return [...a, ...b];
}
const result = concat([1, "two"], [true, new Date()]);
// type: [number, string, boolean, Date]TypeScript has 84.1% satisfaction among current users and ranks among the highest growth-potential languages on JetBrains' Language Promise Index (JetBrains State of Developer Ecosystem 2025, 2025). Advanced questions separate mid-level candidates from senior engineers. Know these patterns cold.
Never type an API response as a hardcoded interface directly - the response shape could change. Use unknown at the boundary and validate with a type guard or schema library.
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const data: unknown = await res.json();
// Validate the shape before trusting it
if (!isUser(data)) throw new Error("Invalid user shape");
return data;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}Interview tip: In production, use Zod or Valibot for schema validation - the guard above is illustrative, not production-grade.
@ts-ignore suppresses the next TypeScript error unconditionally - even if there's no error. @ts-expect-error documents that an error is expected, and itself errors if the suppressed line has no error. Use @ts-expect-error in test files; it keeps suppressions honest.
// @ts-ignore - silent suppression, no feedback if error disappears
// @ts-ignore
const x: number = "string";
// @ts-expect-error - documented intent, fails if error goes away
// @ts-expect-error - intentional type mismatch for testing
const y: number = "string";A generic fetch wrapper uses a type parameter for the expected response shape - keeping the caller's return type specific without casting.
async function apiFetch<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
}
interface Post {
id: number;
title: string;
}
const post = await apiFetch<Post>("/api/posts/1");
// type: Post - no casting at call siteWhen enabled, accessing an index-signature property returns T | undefined instead of T, forcing you to handle the case where the key doesn't exist.
// Without noUncheckedIndexedAccess
const map: Record<string, number> = {};
const value = map["key"]; // type: number - but actually undefined!
console.log(value.toFixed(2)); // 💥 Runtime crash
// With noUncheckedIndexedAccess
const value = map["key"]; // type: number | undefined
console.log(value?.toFixed(2)); // ✅ Safetype AnyFunction = (...args: any[]) => any;
function withLogging<T extends AnyFunction>(fn: T): T {
return function (...args: Parameters<T>): ReturnType<T> {
console.log("Calling:", fn.name, "with", args);
const result = fn(...args);
console.log("Result:", result);
return result;
} as T;
}
function add(a: number, b: number): number {
return a + b;
}
const loggedAdd = withLogging(add);
loggedAdd(2, 3); // Typed as (a: number, b: number) => numberinstanceof narrows class instances. in narrows by checking if a property exists - useful for discriminated unions and plain objects (which you can't use instanceof on).
class ApiError {
constructor(public status: number) {}
}
class NetworkError {
constructor(public message: string) {}
}
function handle(error: ApiError | NetworkError) {
if (error instanceof ApiError) {
console.log(error.status); // ✅ Narrowed to ApiError
} else {
console.log(error.message); // ✅ Narrowed to NetworkError
}
}
// in - for plain objects
type A = { a: string };
type B = { b: number };
function process(obj: A | B) {
if ("a" in obj) {
console.log(obj.a); // type: A
} else {
console.log(obj.b); // type: B
}
}The built-in Readonly only makes top-level properties readonly. A recursive version handles deeply nested objects.
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
interface Config {
server: { host: string; port: number };
db: { url: string };
}
const config: DeepReadonly<Config> = {
server: { host: "localhost", port: 3000 },
db: { url: "mongodb://..." },
};
config.server.host = "prod.example.com"; // ❌ Cannot assign - deeply readonlyAmbient declarations describe the shape of existing JavaScript code - libraries without TypeScript source - so TypeScript can type-check their usage. They live in .d.ts files or declare blocks.
// global.d.ts - declare a browser global
declare const __ENV__: "development" | "production";
// Augmenting an existing module
declare module "*.svg" {
const content: string;
export default content;
}The Builder pattern becomes type-safe in TypeScript by returning this from each builder method, allowing the compiler to track which methods have been called.
class QueryBuilder {
private table = "";
private conditions: string[] = [];
from(table: string): this {
this.table = table;
return this;
}
where(condition: string): this {
this.conditions.push(condition);
return this;
}
build(): string {
return `SELECT * FROM ${this.table} WHERE ${this.conditions.join(" AND ")}`;
}
}
const query = new QueryBuilder()
.from("users")
.where("age > 18")
.where("active = true")
.build();TypeScript supports recursive types natively. The key is giving the type a name so it can reference itself.
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
const data: JSONValue = {
name: "Abhijeet",
scores: [100, 95, 87],
meta: { active: true, tags: ["ts", "react"] },
};
// Recursive tree node
interface TreeNode<T> {
value: T;
children: TreeNode<T>[];
}TypeScript + React is the dominant frontend stack in 2026 - the pairing appears in over 60% of new frontend job postings. These questions test whether you can apply TypeScript's type system to React's component model, hooks, and patterns.
Use an interface or type for props. Prefer interface for public components (extendable). Use React.FC or explicit function signatures - the explicit signature is preferred because React.FC implicitly includes children (React 18+ removed this).
interface ButtonProps {
label: string;
onClick: () => void;
variant?: "primary" | "secondary";
disabled?: boolean;
}
// Preferred - explicit function signature
function Button({
label,
onClick,
variant = "primary",
disabled,
}: ButtonProps) {
return (
<button className={variant} onClick={onClick} disabled={disabled}>
{label}
</button>
);
}TypeScript infers useState from the initial value, but you should annotate when the initial value doesn't reflect the full type range. useRef needs a type for the referenced element.
import { useState, useRef } from "react";
// useState with a union type - initial null, later populated
const [user, setUser] = useState<{ id: number; name: string } | null>(null);
// useRef for a DOM element
const inputRef = useRef<HTMLInputElement>(null);
function focusInput() {
inputRef.current?.focus(); // Optional chain - could be null before mount
}
// useRef for a mutable value (not a DOM element - no null initial)
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);Custom hooks are just functions - type their return value explicitly, especially when returning tuples (TypeScript widens tuple returns to arrays without help).
import { useState, useCallback } from "react";
// Return type annotation prevents tuple widening
function useToggle(initial: boolean): [boolean, () => void] {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle];
}
// Usage - fully typed
const [isOpen, toggleOpen] = useToggle(false);
// isOpen: boolean, toggleOpen: () => voidReact exports typed event interfaces in the React namespace. Always use these - never Event from the DOM, which loses React's synthetic event properties.
import { ChangeEvent, MouseEvent, FormEvent } from "react";
function Form() {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value); // string
};
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
console.log(e.currentTarget.name); // string
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} />
<button name="submit" onClick={handleClick}>
Submit
</button>
</form>
);
}The pattern: create context with createContext, then write a custom hook that asserts the non-null value - so consumers don't need to handle null on every access.
import { createContext, useContext, useState, ReactNode } from "react";
interface AuthContext {
user: { id: string; name: string } | null;
login: (user: { id: string; name: string }) => void;
logout: () => void;
}
const AuthCtx = createContext<AuthContext | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthContext["user"]>(null);
const login = (user: { id: string; name: string }) => setUser(user);
const logout = () => setUser(null);
return (
<AuthCtx.Provider value={{ user, login, logout }}>
{children}
</AuthCtx.Provider>
);
}
// Custom hook - asserts non-null, throws if used outside provider
export function useAuth(): AuthContext {
const ctx = useContext(AuthCtx);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}Interview tip: This pattern is the industry standard for typed context. The | null initial value + custom hook combination catches incorrect usage at runtime with a clear error message.
For most professional roles - yes. 48.8% of professional developers use TypeScript daily, and it's listed as a requirement (not a nice-to-have) in the majority of mid-to-senior frontend job postings (Stack Overflow 2025). Startups may be more lenient, but product companies rarely aren't.
Know TypeScript 5.x features: const type parameters (5.0), satisfies (4.9), variadic tuple improvements (4.0), and infer extends (4.7). Interviewers at top companies specifically ask about post-4.9 features to filter candidates who've kept up to date.
TypeScript type challenges practice → hands-on TypeScript exercises and coding challenges guide
The type-challenges repo on GitHub (sindresorhus/type-challenges) is the industry-standard resource. It ranges from Easy (implementing Partial) to Extreme (implementing a full SQL type system). Aim to complete all Easy and Medium challenges before interviews.
In practice, there's no meaningful runtime difference - both are erased at compile time. TypeScript's checker is marginally faster with interfaces in some scenarios, but this has no observable impact on developer workflows. Choose based on capabilities, not performance.
The median US salary is $110,718/year, with senior engineers averaging $138,917 (PayScale, March 2026). ZipRecruiter reports an average of $129,348/year across all experience levels. FAANG-tier roles with TypeScript expertise frequently exceed $200,000 in total compensation.
TypeScript became the #1 language on GitHub not because companies mandated it - but because developers who use it ship fewer bugs and maintain larger codebases more confidently. The 40% of developers now writing exclusively in TypeScript (State of JS 2025) made that choice for exactly that reason.
For interviews, the pattern is clear: fundamentals get you in the door, generics and utility types get you to mid-level, and conditional types + real-world patterns (satisfies, type guards, context typing) get you the senior offer.
Work through all 50 questions in this guide, build the code snippets from memory, and you'll be prepared for TypeScript rounds at any level - from your first TypeScript role to a staff engineer position.
React interview questions guide → comprehensive React interview questions and answers for 2026