DEV Community

Cover image for 12 Tricky TypeScript Questions That Separate Good Developers From Great Ones
Vitaly Obolensky
Vitaly Obolensky

Posted on

12 Tricky TypeScript Questions That Separate Good Developers From Great Ones

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;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

This enables exhaustive type checking and prevents unintended mutation. Without as const, TypeScript would widen to { apiUrl: string; timeout: number }.

📖 Const Assertions


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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  • value remains string | number after 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 val is definitely string"
  • TypeScript narrows value to string inside 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 {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Type predicates enable custom type guards
  • Must use parameterName is Type syntax
  • 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
Enter fullscreen mode Exit fullscreen mode

Answer:

This conditional type extracts the resolved type from a Promise.

How it works:

  1. T extends Promise<infer U>:

    • Checks if T is assignable to Promise<something>
    • infer U tells TypeScript to infer the type parameter and bind it to U
  2. ? U : T:

    • If T is a Promise, return the inferred type U
    • Otherwise, return T unchanged

Examples:

type A = UnpackPromise<Promise<string>>; // string
type B = UnpackPromise<Promise<{ id: number }>>; // { id: number }
type C = UnpackPromise<number>; // number (not a Promise)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Key points:

  • infer can only be used in extends clauses 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);
}
Enter fullscreen mode Exit fullscreen mode

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 import or export)
  • 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 }
Enter fullscreen mode Exit fullscreen mode

Answer:

This mapped type creates getter methods with transformed property names.

How it works:

  1. [K in keyof T]:

    • Iterates over all keys of T
    • K is each key in turn
  2. as get${Capitalize<string & K>}:

    • Key remapping (TypeScript 4.1+)
    • Transforms each key name
    • Capitalize is a built-in template literal type
    • string & K ensures K is a string (not number or symbol)
  3. : () => 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)
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

Answer:

The type of event is 'onClick' | 'onFocus' | 'onBlur'.

How template literal types work:

  1. `on${Capitalize<EventType>}`:

    • Takes each member of the union EventType
    • Applies Capitalize to each
    • Concatenates with 'on' prefix
  2. 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'
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

Answer:

This recursive type makes all properties deeply readonly — including nested objects and arrays.

How it works:

  1. readonly [K in keyof T]:

    • Makes each property readonly
  2. 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

Result for Config:

{
  readonly api: {
    readonly url: string;
    readonly timeout: number;
  };
  readonly features: readonly string[];
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Limitations:

  • Doesn't handle Date, RegExp, Map, Set correctly
  • 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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Why type aliases don't merge:

type Container = { volume: number };
type Container = { weight: number }; // ❌ Duplicate identifier
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Answer:

These are variance annotations (TypeScript 4.7+) that control how generic types relate to each other in subtyping.

Variance explained:

  1. out T (covariant):

    • T only appears in output positions (return types)
    • Producer<Dog> is assignable to Producer<Animal> if Dog extends Animal
    • Safe because you only get T out
  2. in T (contravariant):

    • T only appears in input positions (parameter types)
    • Consumer<Animal> is assignable to Consumer<Dog> if Dog extends Animal
    • Safe because you only put T in
  3. in out T (invariant):

    • T appears in both input and output
    • Processor<Dog> is NOT assignable to Processor<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
Enter fullscreen mode Exit fullscreen mode

With variance annotations:

interface ReadOnlyBox<out T> {
  getValue(): T;
}

let animalBox: ReadOnlyBox<Animal> = getAnimalBox();
let dogBox: ReadOnlyBox<Dog> = animalBox; // ✅ Safe — covariant
Enter fullscreen mode Exit fullscreen mode

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?

Top comments (0)