12 Tricky TypeScript Questions That Separate Good Developers From Great Ones
Short, sharp, and deliberately uncomfortable — 12 senior-level TypeScript questions for developers who think they know TypeScript. Type-level edge cases, compiler quirks, and the gap between "I use TypeScript" and "I understand how the compiler thinks."
Topics covered: Type System, Generics, Functions, Type Guards, Interfaces and Types, Modules.
1. Ambient declaration vs implementation in global scope
Topic: Variables and Declarations · Difficulty: ⭐⭐ Hard
You need a globally accessible constant in a .d.ts-style setup where the same file may be included multiple times. What is the correct TypeScript declaration pattern — and why?
Answer:
declare const introduces an ambient declaration: it asserts that the symbol exists at runtime without emitting JavaScript. That avoids duplicate-definition errors when the file is processed more than once (for example with legacy --outFile or script concatenation).
A plain const would emit code and can cause redeclaration errors. export const creates a module-scoped binding, not a global. Assigning to globalThis is runtime-only and gives you no compile-time type safety or declaration merging.
Ambient declarations are the standard pattern for global constants in declaration files and multi-entrypoint setups.
📖 Ambient Declarations (TypeScript Handbook)
2. Impact of as const on literal types
Topic: Type System · Difficulty: ⭐⭐ Hard
What is the type of config after the following declaration?
const config = { apiUrl: 'https://api.example.com', timeout: 5000 } as const;
Answer:
as const converts mutable literal types into their narrowest possible readonly literal types — apiUrl becomes the specific string literal 'https://api.example.com', and timeout becomes the numeric literal 5000.
Result type:
{
readonly apiUrl: 'https://api.example.com';
readonly timeout: 5000;
}
This enables exhaustive type checking and prevents unintended mutation. Without as const, TypeScript would widen to { apiUrl: string; timeout: number }.
3. Rest parameter in overloaded function
Topic: Functions · Difficulty: ⭐⭐ Hard
Consider a function with two overloads: one accepting (a: string) and another (a: string, ...rest: number[]). What must be true about the implementation signature's rest parameter?
Answer:
The implementation signature must be callable with all overload call patterns. Since the first overload passes only one argument, rest must be optional — but TypeScript requires rest parameters in implementations to match the most permissive overload.
Here, ...rest: number[] is valid because it's omitted in calls matching the first overload (treated as []). The parameter a remains required; rest is inherently optional when omitted.
Using any[] or unknown[] violates strict typing and isn't required.
4. Type predicates and user-defined type guards
Topic: Type Guards · Difficulty: ⭐⭐ Hard
What's the difference between these two functions, and why does only one act as a proper type guard?
function isString1(val: unknown): boolean {
return typeof val === 'string';
}
function isString2(val: unknown): val is string {
return typeof val === 'string';
}
function process(value: string | number) {
if (isString1(value)) {
console.log(value.toUpperCase()); // ❌ Error
}
if (isString2(value)) {
console.log(value.toUpperCase()); // ✅ OK
}
}
Answer:
The key difference is the return type: boolean vs val is string (type predicate).
isString1 returns boolean:
- Function works correctly at runtime
- But TypeScript doesn't narrow the type
-
valueremainsstring | numberafter the check - Can't call string methods without assertion
isString2 returns val is string:
- This is a type predicate
- Tells TypeScript: "if this returns true, then
valis definitelystring" - TypeScript narrows
valuetostringinside the if-block - Full type safety enabled
Rules for type predicates:
// ✅ Valid: parameter name matches
function isUser(val: unknown): val is User {
return typeof val === 'object' && val !== null && 'name' in val;
}
// ❌ Invalid: parameter name doesn't match
function isUser(val: unknown): data is User {
// ...
}
// ❌ Invalid: return type must be assignable to parameter type
function isString(val: number): val is string {
// ...
}
Key points:
- Type predicates enable custom type guards
- Must use
parameterName is Typesyntax - Parameter name must match function parameter
- Essential for narrowing complex types
📖 Using Type Predicates (TypeScript Handbook)
5. Conditional types with infer keyword
Topic: Generics · Difficulty: ⭐⭐ Hard
What does this conditional type do, and how does the infer keyword work?
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnpackPromise<Promise<string>>; // string
type B = UnpackPromise<number>; // number
Answer:
This conditional type extracts the resolved type from a Promise.
How it works:
-
T extends Promise<infer U>:- Checks if
Tis assignable toPromise<something> -
infer Utells TypeScript to infer the type parameter and bind it toU
- Checks if
-
? U : T:- If
Tis a Promise, return the inferred typeU - Otherwise, return
Tunchanged
- If
Examples:
type A = UnpackPromise<Promise<string>>; // string
type B = UnpackPromise<Promise<{ id: number }>>; // { id: number }
type C = UnpackPromise<number>; // number (not a Promise)
More examples with infer:
// Extract return type of a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
// Extract element type of an array
type ElementType<T> = T extends (infer U)[] ? U : T;
type Num = ElementType<number[]>; // number
Key points:
-
infercan only be used inextendsclauses of conditional types - It creates a type variable that TypeScript infers from the pattern
- Essential for advanced type-level programming
📖 Conditional Types (TypeScript Handbook)
6. Never type and control flow analysis
Topic: Type System · Difficulty: ⭐⭐ Hard
What is the inferred return type of fail() — and why does that matter for code after the call?
function fail(message: string): ??? {
throw new Error(message);
}
Answer:
Because fail() unconditionally throws and has no reachable return, TypeScript infers its return type as never.
That enables control flow analysis: anything after fail() is treated as unreachable, so the compiler won't expect a return value on code paths that call it.
void would mean the function could return undefined, which is false here. unknown and Error are unrelated to the return type of a throwing function.
📖 The never type (TypeScript Handbook)
7. TypeScript's --isolatedModules flag implication
Topic: Modules · Difficulty: ⭐⭐ Hard
What constraint does TypeScript's --isolatedModules compiler option enforce on individual files?
Answer:
--isolatedModules requires each file to be independently valid as a module — meaning it cannot rely on global ambient context or non-module syntax that assumes shared scope (e.g., declare module in non-module files). This is critical for tools like Babel or SWC that transpile files individually without full TS program analysis.
Key constraints:
- All files must be modules (have
importorexport) - Cannot use
const enum - Cannot re-export types without
export type
📖 TypeScript Docs: isolatedModules
8. Mapped types with key remapping
Topic: Generics · Difficulty: ⭐⭐⭐ Hard
What does this mapped type do, and how does the as clause work?
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
}
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }
Answer:
This mapped type creates getter methods with transformed property names.
How it works:
-
[K in keyof T]:- Iterates over all keys of
T -
Kis each key in turn
- Iterates over all keys of
-
as get${Capitalize<string & K>}:- Key remapping (TypeScript 4.1+)
- Transforms each key name
-
Capitalizeis a built-in template literal type -
string & KensuresKis a string (notnumberorsymbol)
-
: () => T[K]:- Each property becomes a function returning the original type
Step-by-step for Person:
type A = UnpackPromise<Promise<string>>; // string
type B = UnpackPromise<Promise<{ id: number }>>; // { id: number }
type C = UnpackPromise<number>; // number (not a Promise)
Other key remapping examples:
// Remove optional modifier
type Required<T> = {
[K in keyof T]-?: T[K];
};
// Filter keys by condition
type RemoveReadonly<T> = {
[K in keyof T as K extends `readonly_${string}` ? never : K]: T[K];
};
Key points:
- Key remapping enables powerful type transformations
- Works with template literal types
- Can filter, rename, or compute new keys
- Essential for advanced utility types
📖 Key Remapping in Mapped Types (TypeScript Handbook)
9. Template literal types
Topic: Type System · Difficulty: ⭐⭐⭐ Hard
What is the type of event in this code, and how do template literal types work?
type EventType = 'click' | 'focus' | 'blur';
type Handler = (event: `on${Capitalize<EventType>}`) => void;
const handler: Handler = (event) => {
console.log(event);
};
Answer:
The type of event is 'onClick' | 'onFocus' | 'onBlur'.
How template literal types work:
-
`on${Capitalize<EventType>}`:- Takes each member of the union
EventType - Applies
Capitalizeto each - Concatenates with
'on'prefix
- Takes each member of the union
-
Result:
-
'click'→Capitalize<'click'>→'Click'→'onClick' -
'focus'→'onFocus' -
'blur'→'onBlur'
-
More examples:
// CSS properties
type CSSProperty = `margin${'Top' | 'Right' | 'Bottom' | 'Left'}`;
// 'marginTop' | 'marginRight' | 'marginBottom' | 'marginLeft'
// Event handlers
type ReactEventHandler = `on${'Click' | 'Change' | 'Submit'}Capture`;
// 'onClickCapture' | 'onChangeCapture' | 'onSubmitCapture'
// URL paths
type APIRoute = `/api/${'users' | 'posts' | 'comments'}`;
// '/api/users' | '/api/posts' | '/api/comments'
Key points:
- Template literal types create string literal types from unions
- Work with built-in string manipulation types (
Capitalize,Uppercase, etc.) - Enable type-safe string patterns
- Essential for type-safe APIs and configuration
📖 Template Literal Types (TypeScript Handbook)
10. Recursive conditional types
Topic: Generics · Difficulty: ⭐⭐⭐ Hard
What does this recursive type do, and why is the conditional check necessary?
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
interface Config {
api: { url: string; timeout: number };
features: string[];
}
type ReadonlyConfig = DeepReadonly<Config>;
Answer:
This recursive type makes all properties deeply readonly — including nested objects and arrays.
How it works:
-
readonly [K in keyof T]:- Makes each property readonly
-
T[K] extends object ? DeepReadonly<T[K]> : T[K]:- If the property value is an object, recursively apply
DeepReadonly - Otherwise, keep the primitive type as-is
- If the property value is an object, recursively apply
Result for Config:
{
readonly api: {
readonly url: string;
readonly timeout: number;
};
readonly features: readonly string[];
}
Why the conditional check is necessary:
Without extends object, you'd try to recurse into primitives:
// ❌ Bad: tries to make string readonly
type BadDeepReadonly<T> = {
readonly [K in keyof T]: DeepReadonly<T[K]>;
};
// Would try: DeepReadonly<string> → { readonly [K in keyof string]: ... }
// This creates infinite recursion or weird results
Limitations:
- Doesn't handle
Date,RegExp,Map,Setcorrectly - Can hit recursion limits with very deep structures
- Arrays become
readonly T[](which is correct)
📖 Recursive Types (TypeScript Handbook)
11. Declaration merging with interfaces
Topic: Interfaces and Types · Difficulty: ⭐⭐⭐ Hard
What happens when you declare the same interface multiple times, and why doesn't this work with type aliases?
interface Box {
width: number;
}
interface Box {
height: number;
}
const box: Box = { width: 10, height: 20 }; // ✅ OK
type Container = { volume: number };
type Container = { weight: number }; // ❌ Error
Answer:
This is declaration merging — a feature unique to interfaces in TypeScript.
How it works:
When you declare the same interface multiple times, TypeScript merges all declarations into a single interface:
// These two declarations:
interface Box {
width: number;
}
interface Box {
height: number;
}
// Merge into:
interface Box {
width: number;
height: number;
}
What can be merged:
- Properties (must have unique names or compatible types)
- Methods (create overloads)
- Nested interfaces
Example with methods:
interface Logger {
log(message: string): void;
}
interface Logger {
log(message: string, level: 'info' | 'error'): void;
}
// Merged result:
interface Logger {
log(message: string): void;
log(message: string, level: 'info' | 'error'): void;
}
Why type aliases don't merge:
type Container = { volume: number };
type Container = { weight: number }; // ❌ Duplicate identifier
Type aliases create a single, immutable binding. You can't redeclare them.
When declaration merging is useful:
- Extending third-party interfaces (e.g., adding properties to
Window) - Module augmentation
- Progressive interface building
Example — extending Window:
interface Window {
myCustomProperty: string;
}
// Now window.myCustomProperty is typed
window.myCustomProperty = 'hello';
Key points:
- Only interfaces support declaration merging
- Type aliases cannot be merged
- Useful for extending existing types
- Common in library type definitions
📖 Declaration Merging (TypeScript Handbook)
12. Variance annotations in TypeScript 4.7+
Topic: Generics · Difficulty: ⭐⭐⭐ Hard
What do the in and out modifiers do in this interface, and why are they needed?
interface Producer<out T> {
get(): T;
}
interface Consumer<in T> {
set(value: T): void;
}
interface Processor<in out T> {
process(value: T): T;
}
Answer:
These are variance annotations (TypeScript 4.7+) that control how generic types relate to each other in subtyping.
Variance explained:
-
out T(covariant):-
Tonly appears in output positions (return types) -
Producer<Dog>is assignable toProducer<Animal>ifDog extends Animal - Safe because you only get
Tout
-
-
in T(contravariant):-
Tonly appears in input positions (parameter types) -
Consumer<Animal>is assignable toConsumer<Dog>ifDog extends Animal - Safe because you only put
Tin
-
-
in out T(invariant):-
Tappears in both input and output -
Processor<Dog>is NOT assignable toProcessor<Animal> - Neither direction is safe
-
Why they're needed:
Without annotations, TypeScript uses structural typing and might allow unsafe assignments:
// Without variance annotations
interface Box<T> {
value: T;
}
let animalBox: Box<Animal> = { value: new Animal() };
let dogBox: Box<Dog> = animalBox; // ❌ Unsafe but allowed!
dogBox.value.bark(); // 💥 Runtime error if value is actually Animal
With variance annotations:
interface ReadOnlyBox<out T> {
getValue(): T;
}
let animalBox: ReadOnlyBox<Animal> = getAnimalBox();
let dogBox: ReadOnlyBox<Dog> = animalBox; // ✅ Safe — covariant
Key points:
-
out= covariant (output only) -
in= contravariant (input only) -
in out= invariant (both) - Catches unsafe type relationships at compile time
- Essential for library authors
📖 Variance Annotations (TypeScript Release Notes)
🎯 Ready to test yourself?
Reading answers is not the same as retrieving them under pressure.
Want to test your knowledge interactively?
- 📚 Full TypeScript Question Bank on GitHub — 30+ questions with detailed explanations, perfect for offline study
- 🚀 Take the Interactive TypeScript Assessment — timed mode, instant feedback, compare your results with other developers
Top comments (0)