12 Tricky TypeScript Questions That Separate Good Developers From Great Ones

작성자

카테고리:

← 피드로
DEV Community · Vitaly Obolensky · 2026-06-09 개발(SW)

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?

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다