TypeScript Developer Interview Questions
Core Overview
Prepare for TypeScript Developer interviews covering TypeScript fundamentals, type inference, interfaces, generics, narrowing, advanced type composition, modules, compiler configuration, JavaScript interoperability, type-safe APIs, and large-scale TypeScript architecture.
Ready to test your knowledge?
Launch a focused practice session to review questions without distraction.
What does TypeScript add to JavaScript, and what happens to TypeScript types when code is compiled?
Direct Answer
TypeScript adds static type checking and type-system syntax to JavaScript development, while type annotations are generally erased from the emitted JavaScript.
Detailed Explanation
TypeScript builds on JavaScript by adding a static type system and compile-time analysis.
JavaScript relationship
Valid JavaScript syntax is broadly part of the TypeScript language, while TypeScript adds additional syntax for describing and checking types.
For example:
`typescript
function greet(name: string): string {
return Hello ${name};
}
The annotations describe expectations to the TypeScript compiler.
Static checking
TypeScript can identify many mistakes before the resulting JavaScript runs.
For example:
`typescript
function double(value: number) {
return value * 2;
}
double("10");
The call violates the declared parameter type.
Types are primarily compile-time constructs
Most TypeScript type syntax does not become runtime validation code.
After compilation, annotations such as : string or : number are removed from the emitted JavaScript.
This means TypeScript does not automatically validate untrusted runtime input from sources such as:
Runtime validation is still required at trust boundaries when data cannot be assumed to satisfy the declared type.
TypeScript does not change JavaScript runtime semantics automatically
The runtime behavior is still governed by JavaScript and the target runtime.
TypeScript primarily helps developers reason about values before execution.
Editor tooling
The type system also enables richer tooling such as:
Compilation
The TypeScript compiler can type-check source code and emit JavaScript according to compiler configuration.
A project normally configures compilation through tsconfig.json.
The central idea is that TypeScript provides additional guarantees during development, but developers must still understand JavaScript runtime behavior.
Code Example
interface Candidate {
id: string;
name: string;
}
function formatCandidate(
candidate: Candidate
): string {
return `${candidate.name} (${candidate.id})`;
}
const candidate: Candidate = {
id: 'candidate-1',
name: 'Alex',
};
console.log(
formatCandidate(candidate)
);Common Interview Pitfalls
- Assuming TypeScript types automatically validate JSON or HTTP request data at runtime.
- Treating TypeScript as a separate runtime unrelated to JavaScript.
- Assuming type annotations are preserved as runtime metadata by default.
- Ignoring JavaScript runtime semantics because the source code type-checks.
- Using type assertions as though they perform runtime validation.
- Assuming successful compilation proves all application behavior is correct.
- Confusing compile-time errors with runtime exceptions.
- Adding TypeScript syntax without enabling meaningful compiler checks.
How are primitive values, arrays, tuples, object types, and optional properties represented in TypeScript?
Direct Answer
TypeScript describes primitive values directly, arrays by element type, tuples by fixed positions, and objects through property types that can be required or optional.
Detailed Explanation
TypeScript provides syntax for describing the shape of common JavaScript values.
Primitive types
Common primitive annotations include:
`typescript
string
number
boolean
For example:
`typescript
let name: string = "Alex";
let score: number = 95;
let active: boolean = true;
Arrays
An array type describes the type of its elements.
`typescript
string[]
or:
`typescript
Array<string>
Both can represent an array of strings.
Tuples
Tuples describe arrays whose positions have known types.
`typescript
const entry: [string, number] = [
"applications",
20,
];
The first position must be compatible with string, while the second must be compatible with number.
Tuples are useful when position itself carries meaning, although an object can often communicate domain meaning more clearly when fields have names.
Object types
Objects can be described inline:
`typescript
function printUser(
user: {
id: string;
name: string;
}
) {}
For reusable shapes, a type alias or interface may be more readable.
Optional properties
A property can be marked optional with ?:
`typescript
type Profile = {
name: string;
headline?: string;
};
Code reading headline must account for the possibility that the property is absent.
Under strict null checking, that commonly means reasoning about undefined.
Readonly properties
TypeScript can prevent assignment through a particular typed reference using readonly.
`typescript
type Candidate = {
readonly id: string;
name: string;
};
This is a compile-time restriction and should not be confused with deep runtime immutability.
Prefer meaningful domain shapes
Use tuples when position is genuinely the intended contract and objects when named fields make meaning clearer.
The type model should improve understanding instead of merely adding syntax.
Code Example
type Candidate = {
readonly id: string;
name: string;
skills: string[];
location?: string;
};
const candidate: Candidate = {
id: 'candidate-1',
name: 'Alex',
skills: [
'TypeScript',
'Node.js',
],
};
const scoreEntry: [
string,
number
] = [
'typescript',
90,
];Common Interview Pitfalls
- Using broad object types when the expected properties are known.
- Using tuples for domain data that would be clearer with named object properties.
- Forgetting that an optional property may be absent.
- Treating readonly as deep runtime immutability.
- Using any arrays when the element type is known.
- Confusing a tuple with an unrestricted array.
- Adding explicit primitive annotations when inference already communicates the type clearly.
- Assuming optional and nullable properties always mean the same thing.
How does TypeScript infer types, and when should developers prefer inference versus explicit type annotations?
Direct Answer
TypeScript derives types from initial values and usage context; annotations are most valuable at important boundaries or when inference would be too broad, unclear, or unintended.
Detailed Explanation
TypeScript does not require explicit annotations on every variable.
The compiler can infer many types from the surrounding program.
Initializer inference
For example:
`typescript
const count = 10;
TypeScript knows this value is numeric without requiring:
`typescript
const count: number = 10;
Unnecessary annotations can add noise without increasing safety.
Function return inference
TypeScript can often infer function return types from returned expressions.
`typescript
function add(a: number, b: number) {
return a + b;
}
The return type can be inferred as numeric.
Explicit return types may still be desirable for public APIs because they create a deliberate contract and can catch accidental changes to implementation output.
Contextual typing
Type information can flow from the surrounding context into an expression.
For example:
`typescript
const names = ["Alex", "Sam"];
names.forEach(name => {
console.log(name.toUpperCase());
});
The callback parameter can be inferred from the array element type.
Literal widening
TypeScript sometimes widens literal values depending on mutability and context.
For example:
`typescript
let status = "draft";
A mutable variable may need to hold other strings later, so its inferred type is broader than the exact literal in many contexts.
By contrast, literal-preserving constructs can maintain narrower types when appropriate.
Annotations at boundaries
Useful places for explicit annotations commonly include:
Do not fight useful inference
This is usually unnecessary:
`typescript
const enabled: boolean = true;
unless the annotation serves a deliberate documentation or API-contract purpose.
Inference is still static
The compiler infers from available type information, not from future runtime data.
If untrusted JSON is asserted to have a particular type, inference does not validate that data.
Good TypeScript uses annotations to establish important contracts while allowing inference to reduce redundant syntax inside well-typed implementations.
Code Example
type Candidate = {
id: string;
score: number;
};
function topCandidate(
candidates: Candidate[]
): Candidate | undefined {
return candidates
.slice()
.sort(
(a, b) =>
b.score - a.score
)[0];
}
const candidates: Candidate[] = [
{
id: 'a',
score: 91,
},
{
id: 'b',
score: 96,
},
];
const top =
topCandidate(candidates);
// top is inferred from the
// function contract.Common Interview Pitfalls
- Annotating every obvious local variable and creating unnecessary type noise.
- Omitting explicit contracts from important public APIs without considering maintainability.
- Assuming inferred types validate untrusted runtime values.
- Being surprised when mutable literal values widen to broader types.
- Using type assertions to force inference into a desired result without proving correctness.
- Duplicating complex inferred implementation types manually and letting them drift.
- Assuming callback parameters always need explicit annotations.
- Treating inferred and explicitly annotated types as different runtime behavior.
How do union types and literal types model values that can exist in several valid states?
Direct Answer
Union types allow multiple possible types, while literal types restrict values to exact alternatives that can form precise state and API contracts.
Detailed Explanation
Union types describe values that may belong to more than one allowed type.
Basic union
`typescript
function formatId(
id: string | number
) {
return String(id);
}
The function accepts either a string or number.
Code cannot automatically use operations that are valid for only one member of the union until the value has been narrowed appropriately.
Literal types
Literal types represent exact values.
`typescript
type Status =
| "draft"
| "published"
| "archived";
This is more precise than declaring status as arbitrary string when only three states are valid.
Function contracts
Literal unions are useful for:
For example:
`typescript
function sort(
direction: "asc" | "desc"
) {}
Invalid string values can be rejected during type checking.
Discriminated unions
Object unions become especially useful when each member contains a common literal property identifying its variant.
`typescript
type Result =
| {
status: "success";
data: string;
}
| {
status: "error";
error: Error;
};
Checking status allows the compiler to narrow the object to the corresponding member.
This can model application states more safely than a single object containing many optional properties.
For example, this weaker model allows contradictory states:
`typescript
type Result = {
success: boolean;
data?: string;
error?: Error;
};
Nothing in that type prevents both data and error from being present.
Literal preservation
Developers should understand when literals remain narrow and when mutable values widen.
as const can preserve literal information and readonly structure where that behavior is intended, but it should not be used merely to silence unrelated type errors.
Union types are most valuable when they encode real alternative states instead of broadening types without purpose.
Code Example
type LoadState =
| {
kind: 'idle';
}
| {
kind: 'loading';
}
| {
kind: 'success';
data: string[];
}
| {
kind: 'error';
message: string;
};
function describe(
state: LoadState
): string {
switch (state.kind) {
case 'idle':
return 'Idle';
case 'loading':
return 'Loading';
case 'success':
return `${state.data.length} items`;
case 'error':
return state.message;
}
}Common Interview Pitfalls
- Using string when only a small fixed set of literal values is valid.
- Accessing member-specific properties of a union before narrowing it.
- Modeling mutually exclusive states with many unrelated optional properties.
- Using type assertions instead of narrowing union members.
- Applying as const solely to silence type errors without understanding the resulting readonly and literal types.
- Creating unions whose members have no meaningful distinguishing behavior.
- Confusing a union type with an intersection type.
- Assuming literal types change JavaScript runtime string behavior.
How do any, unknown, never, null, and undefined differ in TypeScript, and why do strict compiler settings matter?
Direct Answer
any disables many checks, unknown requires validation before use, never represents impossible values, and strict null checking makes null and undefined explicit parts of type contracts.
Detailed Explanation
Several TypeScript types have special roles in how safety and control flow are modeled.
any
any effectively opts a value out of much of TypeScript's checking.
For example:
`typescript
let value: any;
value.missing.deep.call();
The compiler allows operations that may fail at runtime.
any can be useful when migrating untyped JavaScript or interacting with difficult legacy boundaries, but it should not become the default escape hatch.
unknown
unknown represents a value whose type is not yet known safely.
Unlike any, code must establish what the value is before using type-specific operations.
`typescript
function print(value: unknown) {
if (typeof value === "string") {
console.log(value.toUpperCase());
}
}
This makes unknown a strong choice for some untrusted or generic boundaries.
It does not perform runtime validation by itself; it forces callers to narrow or validate before use.
never
never represents a value that should never occur.
It is commonly useful for:
For example:
`typescript
function assertNever(value: never): never {
throw new Error("Unexpected value");
}
If a union gains a new member and a switch no longer handles every case, assigning the remaining value to never can reveal the missing branch.
null and undefined
With strictNullChecks enabled, null and undefined have distinct types and must be included explicitly when they are valid values.
For example:
`typescript
function findCandidate(
id: string
): Candidate | undefined
communicates that lookup may fail.
Callers must handle that possibility before using the candidate.
noImplicitAny
noImplicitAny reports cases where TypeScript would otherwise infer any because sufficient type information was not available.
This prevents accidental untyped holes from spreading through the program.
strict mode
The strict compiler option enables a family of stricter type-checking behaviors.
Teams should normally treat compiler strictness as part of the project's type-safety contract rather than changing it casually to make an error disappear.
The important principle is that unknown asks code to prove safety, while any asks the compiler to stop checking many operations.
Code Example
type ApiResult =
| {
kind: 'success';
value: string;
}
| {
kind: 'failure';
error: string;
};
function assertNever(
value: never
): never {
throw new Error(
`Unexpected value: ${String(value)}`
);
}
function render(
result: ApiResult
): string {
switch (result.kind) {
case 'success':
return result.value;
case 'failure':
return result.error;
default:
return assertNever(result);
}
}Common Interview Pitfalls
- Using any whenever TypeScript reports a difficult type error.
- Treating unknown as though properties can be accessed without narrowing.
- Assuming unknown automatically validates external data at runtime.
- Confusing never with void or undefined.
- Disabling strictNullChecks instead of representing legitimate missing values accurately.
- Using non-null assertions repeatedly instead of proving that a value exists.
- Allowing implicit any values to spread through important APIs.
- Changing strict compiler settings merely to silence application errors.
How would you design TypeScript types for a large application so invalid states are difficult to represent while external data remains safely validated?
Direct Answer
Use precise domain types, discriminated unions, strict nullability, narrow public contracts, unknown at uncertain boundaries, and runtime validation before trusting external data.
Detailed Explanation
Large TypeScript systems benefit most when the type model represents domain invariants instead of simply reproducing unstructured JavaScript objects with annotations.
1. Separate trusted and untrusted data
External input should not become a trusted domain object merely through a type assertion.
Avoid:
`typescript
const candidate =
JSON.parse(input) as Candidate;
The assertion changes what TypeScript believes but does not verify the runtime object.
A safer architecture treats external input as unknown or as an untrusted transport representation and validates it before constructing trusted domain data.
2. Make invalid states harder to represent
Instead of:
`typescript
type RequestState = {
loading: boolean;
data?: Data;
error?: Error;
};
use a union where each valid state has its own contract:
`typescript
type RequestState =
| { kind: "loading" }
| { kind: "success"; data: Data }
| { kind: "failure"; error: Error };
Now contradictory combinations such as success plus error are not part of the declared model.
3. Model absence deliberately
Different concepts should not automatically share one representation.
Examples include:
Choose the representation that matches domain meaning and preserve strict null checking.
4. Prefer narrow domain vocabulary
Instead of arbitrary strings:
`typescript
status: string
prefer a literal union when the domain has a known finite state set.
This helps prevent misspelled or unsupported states from spreading through the system.
5. Protect module boundaries
Export intentional contracts rather than exposing every implementation type.
Public function and module APIs should express:
Implementation details can remain inferred internally.
6. Avoid uncontrolled any
One any can propagate through many expressions and disable useful checking downstream.
When a value is genuinely unknown, prefer unknown and narrow it deliberately.
7. Use inference locally
Do not annotate every local variable.
Strong boundary contracts allow TypeScript to infer implementation details naturally while maintaining safety.
8. Use exhaustive handling for finite states
For critical discriminated unions, an exhaustive switch with never can detect when a new state is introduced without updating all required handlers.
9. Do not confuse static and runtime guarantees
Static types disappear from ordinary emitted JavaScript.
Therefore trust boundaries still need runtime techniques appropriate to the application, such as:
TypeScript describes the program's assumptions; runtime validation proves external values satisfy those assumptions.
10. Prefer meaningful constraints over complex type tricks
Advanced conditional or mapped types can be valuable, but complexity should produce real safety or reusable API value.
A type that requires several minutes to understand can impose significant maintenance cost.
11. Treat compiler configuration as architecture
Options such as strict type checking and strict null handling influence every module.
Changing those settings affects the safety assumptions of the whole codebase.
12. Review types as APIs
Type design should be reviewed similarly to runtime API design.
Ask:
A mature TypeScript architecture uses the type system to make correct application states easy to express and incorrect states difficult to construct.
Code Example
type CandidateId =
string;
type Candidate =
Readonly<{
id: CandidateId;
name: string;
}>;
type CandidateLoadState =
| {
kind: 'idle';
}
| {
kind: 'loading';
}
| {
kind: 'loaded';
candidate: Candidate;
}
| {
kind: 'not-found';
}
| {
kind: 'failed';
error: Error;
};
function parseCandidate(
input: unknown
): Candidate {
if (
typeof input !== 'object' ||
input === null
) {
throw new Error(
'Invalid candidate'
);
}
const value =
input as Record<
string,
unknown
>;
if (
typeof value.id !== 'string' ||
typeof value.name !== 'string'
) {
throw new Error(
'Invalid candidate'
);
}
return {
id: value.id,
name: value.name,
};
}Common Interview Pitfalls
- Casting JSON directly to trusted domain types without runtime validation.
- Modeling mutually exclusive states using many unrelated optional properties.
- Using string for finite domain values that should be constrained.
- Using any throughout shared APIs and losing type information downstream.
- Disabling strict compiler checks to accommodate weak domain modeling.
- Annotating every local value instead of relying on safe inference.
- Using advanced type-system techniques whose complexity provides little safety value.
- Exposing internal implementation types as permanent public API contracts.
- Using non-null assertions repeatedly instead of making absence explicit.
- Assuming compile-time readonly provides complete runtime immutability.
What is the difference between an interface and a type alias in TypeScript, and when would you choose one over the other?
Direct Answer
Interfaces and type aliases can both describe object shapes, but type aliases can represent broader type expressions while interfaces support reopening and declaration merging.
Detailed Explanation
TypeScript is structurally typed, which means compatibility is generally based on the shape of values rather than only on explicitly declared nominal relationships.
Both interfaces and type aliases can name object shapes.
Interface
`typescript
interface Candidate {
id: string;
name: string;
}
Type alias
`typescript
type Candidate = {
id: string;
name: string;
};
For many ordinary object contracts, either form can work.
Interfaces can be extended
`typescript
interface User {
id: string;
}
interface Admin extends User {
permissions: string[];
}
Type aliases can compose with intersections
`typescript
type User = {
id: string;
};
type Admin = User & {
permissions: string[];
};
Type aliases can name more than object shapes
A type alias can represent unions, primitives, tuples, conditional types, mapped types, and many other type expressions.
`typescript
type Status =
| "draft"
| "published";
An interface is primarily intended to describe object-like contracts.
Declaration merging
Interfaces can be reopened and additional members can participate in declaration merging.
`typescript
interface Candidate {
id: string;
}
interface Candidate {
name: string;
}
The resulting interface includes both members.
A type alias cannot be reopened in the same way.
This behavior is useful in some library and declaration-file scenarios but can be undesirable when a domain type is intended to remain closed and locally controlled.
Structural compatibility
A value does not generally need to explicitly declare that it implements an interface merely to be compatible with its shape.
`typescript
interface Named {
name: string;
}
const candidate = {
name: "Alex",
score: 95,
};
function printName(value: Named) {
console.log(value.name);
}
printName(candidate);
The extra score property does not prevent the existing variable from being structurally compatible with Named.
Do not turn interface-versus-type into an absolute style rule. Choose consistently based on the API, composition requirements, declaration-merging needs, and team conventions.
Code Example
interface Identified {
id: string;
}
interface Candidate
extends Identified {
name: string;
}
type CandidateStatus =
| 'draft'
| 'active'
| 'archived';
type CandidateRecord =
Candidate & {
status: CandidateStatus;
};
const candidate:
CandidateRecord = {
id: 'candidate-1',
name: 'Alex',
status: 'active',
};Common Interview Pitfalls
- Claiming interfaces and type aliases have completely different purposes for ordinary object shapes.
- Claiming type aliases cannot compose object types.
- Assuming interfaces can represent every arbitrary union type directly.
- Forgetting that interfaces can participate in declaration merging.
- Using declaration merging accidentally for domain types that should remain closed.
- Treating structural typing as though explicit inheritance is always required.
- Creating a project-wide interface-only or type-only rule without considering actual requirements.
- Confusing interface extension with JavaScript runtime inheritance.
What are generics in TypeScript, and how do generic constraints preserve reusable type information?
Direct Answer
Generics parameterize reusable types and functions while preserving relationships between input and output types; constraints limit which types are accepted.
Detailed Explanation
Generics allow a function, interface, class, or type alias to work with different types while preserving information about those types.
Without generics
A function using any can accept many values but loses important information:
`typescript
function identity(value: any): any {
return value;
}
Calling it with a string still produces an any result from the type system perspective.
Generic identity
`typescript
function identity<T>(value: T): T {
return value;
}
If the caller supplies a string, the return type remains related to that string input type.
Generics therefore preserve relationships that broad types such as any would erase.
Inference
Callers often do not need to provide a type argument explicitly:
`typescript
const value = identity("hello");
TypeScript can infer T from the argument.
Generic interfaces
`typescript
interface ApiResponse<T> {
data: T;
requestId: string;
}
The same response structure can safely carry different payload types.
Constraints
Sometimes a generic algorithm needs a specific capability.
`typescript
function getLength<T extends { length: number }>(
value: T
): number {
return value.length;
}
The constraint says callers may supply different types as long as they provide the required structure.
Do not over-generalize
A generic parameter is useful when it creates a meaningful relationship between types.
This is usually unnecessary:
`typescript
function log<T>(value: T): void {
console.log(value);
}
if the type parameter has no relevance to another input, output, or property.
A simpler parameter such as unknown may communicate the actual contract better.
Multiple type parameters
Use multiple parameters when the API genuinely relates several independent types.
`typescript
type Pair<K, V> = {
key: K;
value: V;
};
The goal of generics is reusable precision, not maximum abstraction.
Code Example
interface ApiResponse<T> {
data: T;
requestId: string;
}
function first<T>(
values: readonly T[]
): T | undefined {
return values[0];
}
type Candidate = {
id: string;
name: string;
};
const response:
ApiResponse<Candidate[]> = {
data: [
{
id: 'candidate-1',
name: 'Alex',
},
],
requestId: 'req-1',
};
const candidate =
first(response.data);Common Interview Pitfalls
- Using any when a generic relationship should preserve input and output types.
- Adding generic parameters that do not participate in a meaningful type relationship.
- Explicitly specifying generic arguments when inference already determines them correctly.
- Using unconstrained generics while accessing properties not guaranteed to exist.
- Constraining a generic more narrowly than the algorithm actually requires.
- Creating many type parameters for simple APIs without increasing type safety.
- Assuming generics perform runtime specialization of JavaScript code.
- Confusing generic constraints with runtime validation.
How do keyof, typeof in type positions, and indexed access types help derive TypeScript types from existing structures?
Direct Answer
keyof derives property-key unions, typeof can derive a type from a value declaration, and indexed access retrieves property types from another type.
Detailed Explanation
TypeScript can derive new types from existing types and values instead of forcing developers to duplicate contracts manually.
keyof
Given an object type:
`typescript
type Candidate = {
id: string;
name: string;
score: number;
};
this:
`typescript
type CandidateKey = keyof Candidate;
produces a union representing the keys of Candidate.
Conceptually:
`typescript
"id" | "name" | "score"
This is useful for APIs that accept property names.
Generic property access
`typescript
function getProperty<T, K extends keyof T>(
object: T,
key: K
): T[K] {
return object[key];
}
The key is constrained to valid keys of T, and the return type depends on the selected key.
Indexed access types
TypeScript can retrieve a property type from another type:
`typescript
type CandidateId = Candidate["id"];
which produces the type of the id property.
A union can also be indexed:
`typescript
type CandidateValue =
Candidate[keyof Candidate];
This produces the union of the property-value types represented by those keys.
typeof in type positions
TypeScript provides a type-query form of typeof that can derive a type from a value declaration.
`typescript
const defaults = {
pageSize: 20,
sort: "recent",
};
type Defaults = typeof defaults;
This avoids manually rewriting the object shape as a second declaration.
The type-level typeof syntax should not be confused with JavaScript runtime typeof even though the spelling is shared.
Derive instead of duplicate
When one type is logically determined by another contract, deriving it can prevent drift.
For example, an API that accepts keys of a model should usually derive those keys through keyof rather than manually maintaining a second string union.
Do not over-couple unrelated contracts
Derivation is useful when there is a genuine source-of-truth relationship.
If two contracts only happen to look similar today but evolve independently, coupling one to the other through type derivation may create the wrong dependency.
Code Example
type Candidate = {
id: string;
name: string;
score: number;
};
function getProperty<
T,
K extends keyof T
>(
object: T,
key: K
): T[K] {
return object[key];
}
const candidate: Candidate = {
id: 'candidate-1',
name: 'Alex',
score: 95,
};
const id =
getProperty(
candidate,
'id'
);
const score =
getProperty(
candidate,
'score'
);Common Interview Pitfalls
- Maintaining manual string-key unions when keyof can derive the actual property keys.
- Using string as a property-key parameter when only keys of a specific object should be accepted.
- Forgetting that indexed access returns the type associated with the selected key.
- Confusing JavaScript runtime typeof with TypeScript type-query typeof.
- Using a runtime value directly where a type is expected without typeof.
- Deriving one contract from another even though they are semantically independent.
- Casting arbitrary keys to keyof T rather than proving that they are valid.
- Assuming every keyof result is always only a string-literal union.
How do mapped types and utility types such as Partial, Required, Readonly, Pick, Omit, and Record transform existing TypeScript types?
Direct Answer
Mapped types transform properties across key unions, while utility types provide common reusable transformations such as optional, required, readonly, selected, omitted, or keyed-object forms.
Detailed Explanation
Mapped types create new object types by iterating over a set of property keys, commonly derived with keyof.
Basic mapped type
`typescript
type Optional<T> = {
[K in keyof T]?: T[K];
};
This produces a new type where each property from T becomes optional.
TypeScript provides standard utility types for many common transformations, so custom versions should not be created when an existing utility clearly represents the contract.
Partial<T>
Makes properties optional.
`typescript
type CandidatePatch =
Partial<Candidate>;
This can be useful for patch-style operations, although business rules may require a more specific update type rather than allowing every property to be optional.
Required<T>
Makes optional properties required.
Readonly<T>
Marks top-level properties readonly through the resulting static type.
It should not be described as recursive deep immutability or runtime freezing.
Pick<T, K>
Creates a type containing selected properties.
`typescript
type CandidateSummary =
Pick<Candidate, "id" | "name">;
Omit<T, K>
Creates a type excluding selected keys.
`typescript
type CandidateInput =
Omit<Candidate, "id">;
Be cautious when domain contracts are independently meaningful. A create-request type is not always semantically “the database object minus id”.
Record<K, V>
Represents an object type whose keys come from K and whose values conform to V.
`typescript
type StatusLabels = Record<
Status,
string
>;
This can help require a value for every member of a known key union.
Mapped modifiers
Mapped types can add or remove modifiers such as optionality or readonly status.
Key remapping
Mapped types can also transform keys using an as clause in advanced scenarios.
Use transformations when relationships are real
Mapped and utility types are powerful when one contract genuinely derives from another.
Do not create long chains of transformations merely to avoid declaring a domain type explicitly when the resulting semantic contract becomes difficult to understand.
Code Example
type Candidate = {
readonly id: string;
name: string;
headline?: string;
score: number;
};
type CandidateSummary =
Pick<
Candidate,
'id' | 'name' | 'score'
>;
type CandidatePatch =
Partial<
Pick<
Candidate,
'name' | 'headline'
>
>;
type CandidateById =
Record<
string,
CandidateSummary
>;Common Interview Pitfalls
- Implementing custom utility types when a built-in utility already expresses the same transformation clearly.
- Using Partial for every update API even when only a subset of fields may legally change.
- Treating Readonly as recursive deep runtime immutability.
- Deriving create-request types mechanically from persistence models without considering API semantics.
- Using Omit chains that make the resulting contract difficult to understand.
- Assuming Record validates object keys at runtime.
- Creating deeply nested mapped transformations that obscure domain meaning.
- Using mapped types where an explicit independent domain interface would be easier to maintain.
How do conditional types, infer, and distributive conditional types work in TypeScript?
Direct Answer
Conditional types select one type or another from a type relationship, infer extracts types during matching, and naked generic unions can distribute across their members.
Detailed Explanation
Conditional types allow TypeScript to choose a resulting type based on assignability relationships.
The basic form is:
`typescript
T extends U
? TrueType
: FalseType
This resembles an if expression at the type level, although it operates on type relationships rather than runtime values.
Basic example
`typescript
type IsString<T> =
T extends string
? true
: false;
Extracting property types
Conditional types can inspect whether a type has a particular structure.
`typescript
type MessageOf<T> =
T extends {
message: unknown;
}
? T["message"]
: never;
infer
Within the true branch of a conditional type, infer can introduce a type variable for part of the matched structure.
`typescript
type ElementType<T> =
T extends readonly (infer U)[]
? U
: T;
For an array type, U becomes its element type.
Another common example extracts a function return type:
`typescript
type FunctionResult<T> =
T extends (...args: any[]) => infer R
? R
: never;
Built-in utilities already solve many common extraction problems, so prefer them when they accurately express the contract.
Distributive conditional types
When a conditional type operates on a naked generic type parameter and receives a union, it can distribute across union members.
For example:
`typescript
type ToArray<T> =
T extends unknown
? T[]
: never;
Given:
`typescript
string | number
this distributes conceptually into:
`typescript
string[] | number[]
rather than:
`typescript
(string | number)[]
Preventing distribution
Wrapping both sides of the extends relationship in tuple-like brackets can suppress that distributive behavior when a union should be treated as one whole type.
`typescript
type ToArrayNonDistributed<T> =
[T] extends [unknown]
? T[]
: never;
Use conditional types deliberately
They are valuable for reusable libraries and APIs where output types genuinely depend on input type structure.
For ordinary business models, a straightforward union or explicit interface is often easier to understand than a deeply recursive conditional type.
Advanced type programming should improve API correctness, not become a puzzle.
Code Example
type AsyncResult<T> =
T extends Promise<infer R>
? R
: T;
type ElementType<T> =
T extends readonly (
infer Item
)[]
? Item
: T;
type Candidate = {
id: string;
};
type LoadedCandidate =
AsyncResult<
Promise<Candidate>
>;
type CandidateItem =
ElementType<
Candidate[]
>;Common Interview Pitfalls
- Treating conditional types as though they execute runtime JavaScript conditions.
- Using infer outside a conditional-type context where inference is permitted.
- Being surprised when conditional types distribute over union members.
- Suppressing distributivity without understanding why the resulting type changes.
- Reimplementing built-in utility types with complex conditionals unnecessarily.
- Building recursive conditional types that exceed the value they provide to callers.
- Using any broadly inside advanced types and accidentally weakening type relationships.
- Choosing a conditional type when a simple union or overload would communicate the API more clearly.
How would you design reusable TypeScript generics and type-level APIs that remain type-safe without becoming too complex for a large engineering team?
Direct Answer
Model meaningful type relationships, derive contracts from stable sources, constrain generics narrowly, expose understandable public types, and avoid complexity that provides little safety.
Detailed Explanation
Advanced TypeScript is most useful when it makes APIs easier and safer to consume.
The objective is not to maximize the amount of type-level programming in the codebase.
1. Start from runtime behavior
A type-level API should describe real runtime behavior rather than invent guarantees the implementation does not provide.
For example, if a runtime function may fail for unknown property names, do not expose a generic signature that falsely implies every string is accepted safely.
2. Preserve meaningful relationships
Generics are most useful when one part of an API depends on another.
For example:
`typescript
function get<T, K extends keyof T>(
object: T,
key: K
): T[K]
captures a real relationship between the input object, selected key, and result type.
3. Use the weakest useful constraint
If an algorithm only needs an id, constrain the generic to that capability:
`typescript
T extends { id: string }
Do not require a large application entity merely because current callers happen to provide one.
Narrow constraints improve reuse and reduce coupling.
4. Derive types from stable sources of truth
Use mechanisms such as:
keyoftypeofwhen one contract genuinely derives from another.
This can prevent duplicated declarations from drifting apart.
Do not derive semantically independent public contracts merely because they currently share fields.
5. Keep domain contracts readable
Compare a direct domain declaration with a chain such as:
`typescript
Omit<
Partial<
Pick<
SomeLargeEntity,
SomeKeyUnion
>
>,
AnotherKey
>
The transformed version may technically work while communicating very little about business meaning.
If the domain concept is important, naming it explicitly can be safer for long-term maintenance.
6. Avoid accidental generic APIs
A generic parameter used once may not represent a meaningful relationship.
For example:
`typescript
function print<T>(value: T): void
may provide no more useful contract than:
`typescript
function print(value: unknown): void
Evaluate whether callers gain information from the generic parameter.
7. Prefer standard utilities
Do not recreate Pick, Omit, Partial, ReturnType, or similar utilities unless the custom semantic behavior is genuinely different.
Standard vocabulary is easier for teams to recognize.
8. Control conditional-type complexity
Conditional and recursive types can create excellent library APIs, but they can also produce:
Complex types should be justified by frequent caller benefit.
9. Design error messages indirectly
A public API with a simpler generic signature often gives callers better compiler diagnostics than one enormous conditional type.
Type-system ergonomics are part of API quality.
10. Expose intentional public contracts
Avoid exporting every internal inferred helper type.
Define stable public names where consumers depend on the contract, while allowing local implementation details to remain inferred.
11. Test types as contracts
For important reusable libraries, verify both:
Runtime unit tests cannot prove that a public generic rejects invalid compile-time usage.
12. Keep static guarantees honest
No generic, mapped type, or conditional type validates unknown runtime JSON merely because the resulting static type is precise.
Runtime validation remains necessary at untrusted boundaries.
A mature TypeScript architecture uses advanced type composition where it reduces caller mistakes and duplication, while keeping the public API understandable to ordinary TypeScript developers.
Code Example
type EntityWithId = {
id: string;
};
type EntityMap<
T extends EntityWithId
> = Record<string, T>;
function indexById<
T extends EntityWithId
>(
values: readonly T[]
): EntityMap<T> {
return values.reduce<
EntityMap<T>
>(
(result, value) => {
result[value.id] =
value;
return result;
},
{}
);
}
type Candidate = {
id: string;
name: string;
score: number;
};
const candidates:
Candidate[] = [
{
id: 'candidate-1',
name: 'Alex',
score: 94,
},
];
const indexed =
indexById(candidates);
// Candidate information is
// preserved through the generic
// relationship.Common Interview Pitfalls
- Using advanced type programming to describe guarantees the runtime implementation does not actually provide.
- Constraining reusable generics to large application entities when only one small capability is required.
- Deriving semantically independent API contracts from persistence models simply to reduce declarations.
- Creating deeply nested utility-type expressions that obscure important domain meaning.
- Adding generic parameters that provide callers no useful relationship or information.
- Reimplementing standard utility types with custom names across the codebase.
- Using recursive conditional types despite poor compiler diagnostics and little caller benefit.
- Exposing internal helper types as permanent public contracts unintentionally.
- Testing runtime behavior without testing important compile-time API expectations.
- Assuming sophisticated type-level APIs validate external runtime data.
How do typeof checks, truthiness checks, equality comparisons, and control flow narrow TypeScript union types?
Direct Answer
TypeScript follows JavaScript control flow and recognized runtime checks to refine a broader union into more specific types within reachable branches.
Detailed Explanation
Narrowing is the process by which TypeScript refines a broad type after code proves something about the runtime value.
typeof narrowing
Consider:
`typescript
function format(value: string | number) {
if (typeof value === "string") {
return value.toUpperCase();
}
return value.toFixed(2);
}
Inside the first branch, TypeScript knows value is a string. In the remaining branch it knows the string possibility has been excluded, leaving number.
Common useful typeof results include:
stringnumberbooleanbigintsymbolundefinedfunctionobjectRemember ordinary JavaScript behavior such as typeof null === "object" when designing guards.
Truthiness narrowing
JavaScript conditions can narrow values based on truthiness.
`typescript
function printName(
name: string | null | undefined
) {
if (name) {
console.log(name.toUpperCase());
}
}
The true branch excludes nullish and other falsy possibilities represented by the type.
However, truthiness is not always equivalent to “value exists”. An empty string is also falsy.
If an empty string is valid domain data, an explicit nullish check may better represent the intended rule.
Equality narrowing
Comparisons can establish relationships between values.
`typescript
function compare(
a: string | number,
b: string | boolean
) {
if (a === b) {
// both must be compatible
// with their shared possibility: string
}
}
Literal comparisons can also narrow union states:
`typescript
if (status === "published") {
// status is narrowed here
}
Control-flow analysis
TypeScript tracks assignments, returns, branches, and reachability.
If one branch returns, types in the remaining code can be narrower because the possibilities handled by the returned branch are no longer reachable.
Choose checks that match domain semantics
Do not use a truthiness check merely because it narrows successfully.
Ask whether values such as 0, false, or "" are valid values that should survive the condition.
Code Example
function normalizeValue(
value:
| string
| number
| null
): string {
if (value === null) {
return 'missing';
}
if (
typeof value === 'number'
) {
return value.toFixed(2);
}
return value.trim();
}Common Interview Pitfalls
- Assuming truthiness means only null and undefined are excluded.
- Forgetting that an empty string, zero, and false are falsy JavaScript values.
- Forgetting JavaScript typeof null behavior.
- Using type assertions when ordinary control-flow narrowing already proves the type.
- Repeating checks after TypeScript has already narrowed a value sufficiently.
- Writing runtime conditions that do not match the domain semantics merely to satisfy the compiler.
- Assuming narrowing permanently changes the declared variable type everywhere.
- Ignoring assignments that can change a narrowed variable later in the control flow.
How do the in operator, instanceof, and property checks help narrow object types in TypeScript?
Direct Answer
The in operator narrows based on property presence, instanceof narrows class-like values through runtime prototype checks, and property checks can distinguish object variants.
Detailed Explanation
Object unions often need runtime checks before code can safely access member-specific properties.
The in operator
JavaScript's in operator checks whether a property exists on an object or its prototype chain.
TypeScript can use that check for narrowing.
`typescript
type Success = {
data: string;
};
type Failure = {
error: string;
};
function print(
result: Success | Failure
) {
if ("error" in result) {
console.log(result.error);
} else {
console.log(result.data);
}
}
The check provides evidence about which union members are possible.
instanceof
instanceof performs a JavaScript runtime prototype-chain check.
`typescript
function describe(
value: Date | string
) {
if (value instanceof Date) {
return value.toISOString();
}
return value.toUpperCase();
}
This is appropriate when the runtime value genuinely has a class/prototype identity.
It should not be used to distinguish plain JSON objects that only resemble a class interface.
Property checks
Explicit property values can also distinguish variants.
A shared literal discriminator is usually clearer for closed domain unions:
`typescript
type Result =
| {
kind: "success";
value: string;
}
| {
kind: "failure";
message: string;
};
Checking kind communicates the intended variants directly.
Optional properties complicate presence checks
If several members can contain the checked property optionally, in may not isolate one single variant as strongly as expected.
The type model itself determines what the guard proves.
Runtime reality matters
A TypeScript interface does not create a runtime constructor.
Therefore this idea is invalid:
`typescript
value instanceof Candidate
when Candidate is only an interface.
Use guards that correspond to actual runtime information.
Code Example
class ApiError extends Error {
constructor(
message: string,
readonly statusCode: number
) {
super(message);
}
}
function getMessage(
error: ApiError | Error | string
): string {
if (
error instanceof ApiError
) {
return `${error.statusCode}: ${error.message}`;
}
if (
error instanceof Error
) {
return error.message;
}
return error;
}Common Interview Pitfalls
- Using instanceof with a TypeScript interface that has no runtime constructor.
- Assuming in checks only own properties rather than JavaScript property presence semantics.
- Using instanceof to validate plain JSON objects from an API.
- Assuming a property-presence check always identifies exactly one union member.
- Adding arbitrary marker properties instead of designing a clear discriminator.
- Casting an object before checking whether required runtime properties exist.
- Confusing compile-time structural typing with JavaScript prototype identity.
- Assuming a class type guarantees untrusted input was constructed by that class.
How do user-defined type predicates and assertion functions help TypeScript narrow values?
Direct Answer
Type predicates tell TypeScript what a successful Boolean guard proves, while assertion functions declare that returning normally establishes a condition or narrowed type.
Detailed Explanation
Sometimes built-in narrowing expressions are repeated often enough that they should be encapsulated in a reusable function.
User-defined type predicate
A predicate can return a type predicate:
`typescript
function isCandidate(
value: unknown
): value is Candidate {
// runtime checks
}
The syntax:
`typescript
value is Candidate
tells TypeScript what a true result means for that parameter.
For example:
`typescript
if (isCandidate(input)) {
console.log(input.name);
}
Inside the branch, input is narrowed to Candidate.
The implementation must be correct
TypeScript trusts the predicate signature.
A predicate that returns true without adequately validating the runtime value can create an unsound static belief.
For example, this would be unsafe:
`typescript
function isCandidate(
value: unknown
): value is Candidate {
return true;
}
The predicate syntax itself does not perform validation.
Assertion functions
An assertion function describes a function that throws or otherwise fails to return normally when a condition is not satisfied.
A type-narrowing assertion can look like:
`typescript
function assertCandidate(
value: unknown
): asserts value is Candidate {
if (!isCandidate(value)) {
throw new Error(
"Invalid candidate"
);
}
}
After a successful call:
`typescript
assertCandidate(input);
TypeScript treats input as Candidate in subsequent reachable code.
Condition assertions
Assertions can also declare:
`typescript
asserts condition
when the function establishes an arbitrary condition rather than one named parameter type.
Predicates versus assertions
Use a predicate when the caller should branch on true or false.
Use an assertion function when invalid input should stop the current control flow by throwing or otherwise not returning normally.
Centralize trust-boundary validation
Predicates and assertion functions can make parsing boundaries easier to reuse, but they should perform real runtime checks before upgrading an unknown value to a trusted domain type.
Code Example
type Candidate = {
id: string;
name: string;
};
function isCandidate(
value: unknown
): value is Candidate {
if (
typeof value !== 'object' ||
value === null
) {
return false;
}
const record =
value as Record<
string,
unknown
>;
return (
typeof record.id ===
'string' &&
typeof record.name ===
'string'
);
}
function assertCandidate(
value: unknown
): asserts value is Candidate {
if (!isCandidate(value)) {
throw new Error(
'Invalid candidate'
);
}
}Common Interview Pitfalls
- Writing a type predicate whose implementation does not actually establish the claimed type.
- Treating the value-is-Type syntax itself as runtime validation.
- Using assertions to silence uncertainty instead of checking runtime input.
- Using an assertion function that returns normally even when its claimed condition is false.
- Casting to the final domain type inside the validator before validating required fields.
- Using assertion functions where callers actually need recoverable Boolean branching.
- Duplicating trust-boundary validation inconsistently throughout the application.
- Assuming TypeScript verifies that every user-defined predicate is logically correct.
How do discriminated unions and the never type support exhaustive handling of application states?
Direct Answer
A shared literal discriminator lets TypeScript identify union variants, while never can reveal branches that remain unhandled after exhaustive narrowing.
Detailed Explanation
Discriminated unions are one of the most useful ways to model finite application states in TypeScript.
Each union member contains a shared property whose value is a distinct literal.
`typescript
type Result =
| {
kind: "loading";
}
| {
kind: "success";
data: string[];
}
| {
kind: "failure";
error: Error;
};
The kind field is the discriminator.
Narrowing
`typescript
switch (result.kind) {
case "loading":
break;
case "success":
console.log(result.data);
break;
case "failure":
console.error(result.error);
break;
}
Each branch receives the corresponding member type.
Why this is safer than optional fields
A weaker model might be:
`typescript
type Result = {
loading: boolean;
data?: string[];
error?: Error;
};
That model can represent contradictory states such as loading plus error plus data simultaneously.
A discriminated union declares the allowed combinations explicitly.
Exhaustiveness with never
After all valid union members have been eliminated through narrowing, the remaining value should have type never.
`typescript
function assertNever(
value: never
): never {
throw new Error(
Unhandled state: ${String(value)}
);
}
Then:
`typescript
default:
return assertNever(result);
If a new union member is added later but the switch is not updated, the remaining value is no longer assignable to never, producing a type error.
Exhaustiveness is valuable for domain evolution
Finite states such as:
can benefit because adding a new variant causes relevant handlers to reveal missing cases.
Do not add a broad catch-all member casually
If a union includes something like:
`typescript
{ kind: string }
then the discriminator loses much of its finite-state value.
Keep the union closed when the domain is genuinely finite.
Code Example
type JobState =
| {
kind: 'saved';
}
| {
kind: 'applied';
appliedAt: Date;
}
| {
kind: 'interview';
scheduledAt: Date;
}
| {
kind: 'rejected';
reason?: string;
};
function assertNever(
value: never
): never {
throw new Error(
`Unhandled job state: ${String(
value
)}`
);
}
function label(
state: JobState
): string {
switch (state.kind) {
case 'saved':
return 'Saved';
case 'applied':
return 'Applied';
case 'interview':
return 'Interview';
case 'rejected':
return 'Rejected';
default:
return assertNever(state);
}
}Common Interview Pitfalls
- Modeling mutually exclusive states with many unrelated optional properties.
- Using a discriminator typed as arbitrary string instead of finite literals.
- Adding a new union member without updating all exhaustive handlers.
- Using a default branch that silently hides unhandled variants.
- Casting a remaining union member to never instead of proving exhaustiveness.
- Giving several variants the same discriminator literal accidentally.
- Using discriminated unions for states that are not actually mutually exclusive.
- Assuming exhaustiveness checking performs runtime validation of external values.
How should TypeScript applications handle caught errors and unknown failure values safely?
Direct Answer
Treat caught failures as unknown until narrowed, recognize that JavaScript can throw arbitrary values, normalize errors at boundaries, and expose deliberate domain failure contracts.
Detailed Explanation
JavaScript does not require thrown values to be instances of Error.
Code can throw:
Therefore immediately assuming that every caught value has an Error.message property is unsafe.
unknown catch variables
TypeScript supports treating catch variables as unknown through useUnknownInCatchVariables.
`typescript
try {
await runTask();
} catch (error) {
if (error instanceof Error) {
console.error(error.message);
}
}
When the catch variable is unknown, code must narrow it before accessing type-specific properties.
Normalize errors
A reusable boundary can convert arbitrary thrown values into a predictable form.
`typescript
function toError(
value: unknown
): Error {
if (value instanceof Error) {
return value;
}
return new Error(String(value));
}
The normalization policy should match security and observability requirements.
Do not automatically expose arbitrary internal messages to users.
Domain errors
Applications can distinguish expected business failures from unexpected technical failures.
For example:
`typescript
type SaveResult =
| {
kind: "success";
}
| {
kind: "validation-error";
fields: string[];
}
| {
kind: "conflict";
};
Expected outcomes can often be returned through explicit domain results rather than being represented exclusively by exceptions.
Exceptions remain useful
Exceptions can still represent failures such as:
The choice should be intentional.
Do not catch and erase failures
This is dangerous:
`typescript
try {
await save();
} catch {
return undefined;
}
unless undefined is explicitly the intended failure contract and diagnostic information is handled elsewhere.
Error boundaries should reduce uncertainty
At infrastructure boundaries, normalize unknown failures into application-specific errors or result types.
Internal code should then work with a smaller, documented set of failure states.
Code Example
type AppError =
| {
kind: 'validation';
message: string;
}
| {
kind: 'network';
message: string;
}
| {
kind: 'unexpected';
cause: Error;
};
function normalizeError(
value: unknown
): AppError {
if (value instanceof Error) {
return {
kind: 'unexpected',
cause: value,
};
}
return {
kind: 'unexpected',
cause: new Error(
String(value)
),
};
}
async function execute(): Promise<void> {
try {
await doWork();
} catch (error) {
const appError =
normalizeError(error);
handleError(appError);
}
}Common Interview Pitfalls
- Assuming every thrown JavaScript value is an Error instance.
- Accessing error.message directly from an unknown catch variable without narrowing.
- Casting caught values to Error merely to silence the compiler.
- Swallowing failures without preserving diagnostics or defining intentional semantics.
- Exposing internal infrastructure error messages directly to end users.
- Using exceptions for every expected domain outcome without considering explicit result types.
- Returning broad unknown errors deep into domain logic instead of normalizing at boundaries.
- Assuming useUnknownInCatchVariables performs runtime validation.
How would you design a large TypeScript application so external data, state transitions, and failures remain type-safe without relying on assertions?
Direct Answer
Treat external values as untrusted, validate once at boundaries, model valid states with discriminated unions, normalize failures, and use exhaustive transitions to prevent impossible states.
Detailed Explanation
A large TypeScript application becomes safer when uncertainty is concentrated at boundaries instead of spreading through every module.
1. Identify trust boundaries
Common trust boundaries include:
A static TypeScript declaration does not prove that those runtime values satisfy the declaration.
Start uncertain values as unknown or another explicitly untrusted representation.
2. Parse once into trusted domain data
Create a boundary layer that performs real runtime validation.
Conceptually:
`text
unknown external value
↓
runtime parsing / validation
↓
trusted domain type
Downstream business logic should not repeatedly cast the same value.
3. Avoid assertion-driven architecture
Repeated patterns such as:
`typescript
value as Candidate
or:
`typescript
candidate!.name
are signals that the application may be bypassing unresolved uncertainty instead of modeling it.
Assertions are sometimes appropriate, but they should not replace proof at routine boundaries.
4. Model lifecycle states explicitly
Suppose a job application can be:
A discriminated union can encode what information belongs to each state.
For example, an offered state can require offeredAt, while a saved state cannot accidentally contain offer-specific information.
5. Model transitions as functions
Instead of allowing arbitrary object mutation, define operations whose input states and output states represent legal transitions.
For example:
`typescript
submit(savedApplication)
can return a submitted application type.
This makes invalid transitions harder to express through ordinary application APIs.
6. Keep runtime authorization separate
Types can prevent accidental misuse by developers, but they do not prove that a user is authorized to perform an operation.
Security and authorization remain runtime responsibilities.
7. Normalize infrastructure failures
Infrastructure code may encounter unknown thrown values.
Convert these at boundaries into a small application error vocabulary such as:
Do not expose every vendor-specific error shape throughout the domain layer.
8. Separate expected outcomes from exceptional failures
Expected business outcomes can often be represented as discriminated results.
Unexpected infrastructure failures may still use exceptions internally before being normalized.
The important point is that callers receive an intentional contract.
9. Use exhaustive handling
Critical state machines and domain-result handling should use exhaustive switches or equivalent structures so adding a new variant reveals handlers that must be updated.
never is useful for this compile-time enforcement.
10. Keep predicates honest
A custom predicate is part of the application's trust architecture.
If it claims:
`typescript
value is Candidate
its runtime checks must actually establish the required Candidate contract.
A false predicate guarantee is equivalent to lying to downstream type checking.
11. Separate transport and domain models
An API response shape may use:
The domain model does not necessarily need to preserve those transport details.
Parse transport data into the representation that best expresses application invariants.
12. Avoid giant unions spanning unrelated concerns
Finite state modeling is valuable, but one enormous union covering networking, UI, persistence, permissions, and domain workflow can become unmaintainable.
Use separate state models for separate responsibilities and compose them at meaningful boundaries.
13. Test both runtime validation and compile-time contracts
Runtime tests should prove parsers reject malformed values.
Type-level checks should prove invalid state combinations and illegal transitions cannot be expressed through normal public APIs.
14. Keep escape hatches visible
Review uses of:
anyas!These constructs are sometimes necessary, but concentrated use makes uncertainty easier to audit.
A mature TypeScript architecture does not eliminate uncertainty. It identifies where uncertainty enters, proves what can be proven, and prevents that uncertainty from silently spreading.
Code Example
type SavedApplication = {
kind: 'saved';
id: string;
};
type SubmittedApplication = {
kind: 'submitted';
id: string;
submittedAt: Date;
};
type ApplicationState =
| SavedApplication
| SubmittedApplication;
function submit(
application:
SavedApplication,
now: Date
): SubmittedApplication {
return {
kind: 'submitted',
id: application.id,
submittedAt: now,
};
}
function assertNever(
value: never
): never {
throw new Error(
`Unexpected state: ${String(
value
)}`
);
}
function renderState(
state: ApplicationState
): string {
switch (state.kind) {
case 'saved':
return 'Saved';
case 'submitted':
return `Submitted at ${state.submittedAt.toISOString()}`;
default:
return assertNever(state);
}
}Common Interview Pitfalls
- Casting external data directly into domain types without performing runtime validation.
- Using non-null assertions throughout business logic instead of representing absence accurately.
- Allowing arbitrary mutation to move domain objects into invalid combinations of state.
- Treating TypeScript types as authorization or security enforcement.
- Leaking third-party infrastructure error shapes throughout the domain model.
- Using one giant union to combine unrelated UI, networking, persistence, and business concerns.
- Writing custom predicates that claim stronger guarantees than their runtime checks provide.
- Using broad any values across trust boundaries and allowing uncertainty to propagate.
- Using default switch branches that hide newly introduced domain variants.
- Testing runtime code without testing important compile-time state constraints.
How do imports, exports, modules, and type-only imports work in TypeScript?
Direct Answer
Files with top-level imports or exports are modules; normal imports can have runtime meaning, while import type and export type represent type-only dependencies that are erased from emitted JavaScript.
Detailed Explanation
TypeScript uses JavaScript module syntax and adds type-aware behavior on top of it.
What makes a file a module
A file containing a top-level import or export is treated as a module.
For example:
`typescript
export function formatName(
name: string
): string {
return name.trim();
}
Another module can import that value:
`typescript
import { formatName } from "./format-name.js";
The exact import specifier and emitted module behavior depend on the project runtime and compiler configuration.
Named exports
`typescript
export const version = "1.0";
export function parse() {}
can be imported with corresponding named imports.
Default exports
JavaScript modules can also expose a default export.
`typescript
export default class CandidateService {}
Whether a project prefers named or default exports is often a library or team API-design decision rather than a TypeScript type-safety requirement.
Type-only imports
A value imported only for use as a type can be expressed explicitly:
`typescript
import type { Candidate } from "./types.js";
import type is removed from the emitted JavaScript because it represents only a type dependency.
Similarly:
`typescript
export type { Candidate };
exports a type without creating a runtime JavaScript export for that declaration.
Types and values occupy different roles
Some declarations exist only in the type system, while others create runtime JavaScript values.
For example, an interface is type-only. A class creates both a type and a runtime constructor value.
Understanding this distinction helps prevent imports that look valid to the type checker but do not correspond to runtime values.
Modules create boundaries
Use module exports intentionally. Export the public surface consumers need rather than exposing every internal helper automatically.
Module design affects both runtime dependency structure and the type-level API of the project.
Code Example
// candidate.ts
export type Candidate = {
id: string;
name: string;
};
export function formatCandidate(
candidate: Candidate
): string {
return `${candidate.name} (${candidate.id})`;
}
// consumer.ts
import {
formatCandidate,
} from './candidate.js';
import type {
Candidate,
} from './candidate.js';
const candidate: Candidate = {
id: 'candidate-1',
name: 'Alex',
};
console.log(
formatCandidate(candidate)
);Common Interview Pitfalls
- Assuming every TypeScript import necessarily produces a runtime JavaScript import.
- Importing an interface as though it were a runtime constructor.
- Using a type-only import when the imported symbol is also required at runtime.
- Assuming TypeScript module resolution and JavaScript runtime resolution are unrelated.
- Exporting every internal helper and unintentionally creating a large public API.
- Treating default exports as inherently more type-safe than named exports.
- Ignoring the configured runtime module system when writing import specifiers.
- Confusing a class type with the runtime class constructor value.
What is tsconfig.json, and why should TypeScript compiler options such as strict, target, module, and moduleResolution be chosen deliberately?
Direct Answer
tsconfig.json defines a TypeScript project and its compiler behavior; strictness, emitted language level, module format, and module resolution should match the project’s safety and runtime requirements.
Detailed Explanation
tsconfig.json describes a TypeScript project and configures how its source files are interpreted, checked, and emitted.
Project boundary
The configuration identifies which files belong to the project through mechanisms such as:
filesincludeexcludeIt also defines compiler options for those files.
strict
The strict option enables a family of stronger type-checking behaviors.
These checks help make assumptions such as nullability, function compatibility, and implicit typing more explicit.
Teams should normally treat strictness as part of the project contract rather than disabling checks individually whenever code becomes difficult to type.
target
target influences the JavaScript language level TypeScript emits.
For example, the target affects whether newer JavaScript syntax can remain in the emitted output or must be transformed for older runtimes.
It should therefore reflect the environments that will execute the JavaScript.
module
module controls important aspects of how TypeScript models and emits module syntax.
The correct setting depends on whether the project runs directly in Node.js, through a bundler, in a browser-oriented environment, or under another host.
moduleResolution
Module resolution controls how TypeScript resolves import specifiers to files and packages for type checking.
It should model the runtime or bundler that will ultimately resolve those imports.
Do not choose a resolution mode merely because it is newest or because another project uses it.
lib
The lib option controls which built-in environment type declarations are available.
For example, server-only code should not accidentally depend on browser DOM globals merely because an overly broad configuration includes them.
noEmit
Projects whose JavaScript is emitted by another build tool can use TypeScript only for checking with an appropriate noEmit setup.
Configuration is architecture
Compiler settings affect every file in a project.
Changing module behavior, strictness, or environment libraries can therefore alter assumptions across the codebase.
Treat configuration changes like application architecture changes: understand why the option exists, what environment it represents, and what code assumptions will change.
Code Example
{
"compilerOptions": {
"target": "ES2022",
"strict": true,
"noEmit": true,
"module": "preserve",
"moduleResolution": "bundler"
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
]
}Common Interview Pitfalls
- Copying a tsconfig from another project without checking whether the runtime environment is the same.
- Disabling strict checks simply to make existing type errors disappear.
- Choosing moduleResolution independently from how imports are resolved at runtime.
- Including browser libraries in server-only projects without considering accidental DOM dependencies.
- Assuming target controls only type checking and has no relationship to emitted JavaScript.
- Changing major compiler settings without running full project verification.
- Using one configuration for unrelated runtime environments even when their globals and module behavior differ.
- Assuming every project must use identical TypeScript compiler options.
How should TypeScript moduleResolution be configured, and why must compile-time module resolution agree with the runtime or bundler?
Direct Answer
TypeScript should resolve modules using rules that model the actual host environment, including package exports and imports where relevant, so type checking does not approve imports the runtime resolves differently.
Detailed Explanation
When TypeScript encounters an import, it must determine which source or declaration file represents that module during type checking.
That process is module resolution.
Runtime and compiler must agree
Consider:
`typescript
import { parse } from "some-package";
Several systems are involved:
1. TypeScript resolves the package for type checking
2. A bundler or runtime resolves it when executing the emitted application
If these systems follow incompatible rules, code may compile successfully but fail at runtime or resolve a different package entry than expected.
Choose a mode for the host
TypeScript provides resolution strategies designed for different environments.
Modern Node.js projects should use the Node-aware modes appropriate to their module configuration.
Bundled applications can use the resolution mode intended to model bundler behavior when that matches the build system.
Do not treat one moduleResolution value as universally correct for every TypeScript application.
package.json exports and imports
Modern package resolution can involve package exports and imports mappings.
These mappings define which package entry points are externally available and can vary by conditions.
A package may intentionally expose:
`text
package-root
package/subpath
while preventing consumers from importing arbitrary internal files.
TypeScript resolution should respect the package model expected by the runtime or bundler.
File extensions and ESM
In runtimes with strict ECMAScript-module resolution behavior, import specifiers can have requirements that differ from historical CommonJS patterns.
TypeScript configuration should model those requirements rather than hiding them during checking.
paths
paths can tell TypeScript how import specifiers map during type resolution.
It does not by itself rewrite emitted JavaScript import paths for the runtime.
If an alias such as:
`typescript
import { x } from "@/lib";
works only because TypeScript understands paths, the actual runtime or bundler must also understand an equivalent mapping.
Debug the entire chain
When module resolution fails, inspect:
modulemoduleResolutionA TypeScript module-resolution error is often an environment-modeling problem rather than simply a missing file.
Code Example
// A bundler-oriented project may
// intentionally model bundler resolution.
//
// tsconfig.json
{
"compilerOptions": {
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true
}
}
// The build tool/runtime must still
// understand every emitted/imported
// module specifier.Common Interview Pitfalls
- Choosing moduleResolution without considering the runtime or bundler.
- Assuming successful TypeScript resolution guarantees runtime resolution.
- Using tsconfig paths aliases without configuring the runtime or build tool appropriately.
- Importing package-private internal files that are not part of the supported exported API.
- Ignoring package.json exports and imports behavior.
- Mixing CommonJS and ESM assumptions without understanding the execution environment.
- Changing module settings to silence one error without checking emitted runtime behavior.
- Assuming every TypeScript project should use the same modern module-resolution mode.
What are TypeScript declaration files, and how should developers provide or consume types for existing JavaScript libraries?
Direct Answer
Declaration files describe the type-level API of JavaScript code without implementing its runtime behavior, allowing TypeScript consumers to type-check existing libraries.
Detailed Explanation
A declaration file uses the .d.ts extension to describe a JavaScript API to TypeScript.
It contains declarations rather than the normal runtime implementation of the library.
Purpose
Suppose a JavaScript package exposes:
`javascript
export function parse(value) {
// runtime implementation
}
A corresponding declaration could describe its contract:
`typescript
export function parse(
value: string
): Result;
TypeScript consumers can then receive type checking, navigation, and editor tooling even though the runtime implementation is JavaScript.
Declaration files describe reality
The declaration must match the JavaScript behavior.
If the .d.ts claims a function always returns string but the implementation can return undefined, consumers receive a false static guarantee.
A declaration file is therefore part of the library API contract.
Generating declarations
TypeScript can generate declaration files for TypeScript or JavaScript project inputs when declaration generation is configured appropriately.
This is especially useful for libraries because consumers need the external type surface without needing the library's original source implementation.
JavaScript migration
TypeScript can participate in projects containing JavaScript.
Options such as allowJs permit JavaScript files to be part of a TypeScript project, while additional checking can be introduced as migration progresses.
This supports gradual adoption rather than requiring an entire codebase to be converted at once.
Third-party libraries
A JavaScript dependency can obtain TypeScript types from several places, including:
Prefer declarations maintained close to the actual library when they accurately describe the implementation.
Module declaration layout
For libraries with several public modules, declaration-file structure should reflect the public package/module layout.
Do not expose internal implementation paths merely because they happen to exist in the repository.
Do not implement runtime behavior in .d.ts
Declarations tell TypeScript what exists; they do not replace the JavaScript that must exist at runtime.
A type declaration cannot create a missing function, class, variable, or module when the application executes.
Code Example
// legacy-library.d.ts
declare module 'legacy-library' {
export interface ParseResult {
value: string;
valid: boolean;
}
export function parse(
input: string
): ParseResult;
}
// application.ts
import {
parse,
} from 'legacy-library';
const result =
parse('candidate');
console.log(
result.value
);Common Interview Pitfalls
- Treating a declaration file as though it implements the runtime JavaScript library.
- Writing declaration signatures that are more optimistic than the actual JavaScript behavior.
- Assuming installing types creates a missing runtime dependency.
- Duplicating library declarations locally when accurate official declarations already exist.
- Exposing private package internals in public declaration files unintentionally.
- Converting an entire JavaScript codebase at once when gradual TypeScript adoption would be safer.
- Using broad any declarations for a library whose API shape is known.
- Assuming generated declarations automatically represent a well-designed public API.
How do TypeScript project references and build mode help organize and build large codebases?
Direct Answer
Project references split a large TypeScript program into dependent projects with explicit boundaries, while composite projects and build mode let TypeScript understand dependency order and incremental outputs.
Detailed Explanation
As a TypeScript codebase grows, placing every source file into one enormous compiler project can make boundaries unclear and development tooling more expensive.
Project references allow a codebase to be split into smaller TypeScript projects with explicit dependencies between their tsconfig.json files.
References
A project can declare dependencies:
`json
{
"references": [
{ "path": "../shared" }
]
}
This tells TypeScript that one project depends on another project rather than merely discovering all source files as one flat compilation unit.
Composite projects
Referenced projects use compiler constraints associated with composite so TypeScript can understand their outputs and build relationships reliably.
Build mode
The compiler supports build mode:
`text
tsc -b
which understands project-reference graphs and can determine build ordering across dependent projects.
This is preferable to manually compiling packages in arbitrary order.
Incremental work
TypeScript can retain build information so future compilations do not need to repeat all previous work unnecessarily.
The exact performance benefit depends on project structure and workload.
Architecture benefits
References can establish meaningful boundaries such as:
`text
core
↓
domain
↓
api
or separate runtime environments such as:
Each can use configuration appropriate to its environment.
References are not a license for arbitrary package splitting
Creating hundreds of tiny projects can create its own operational complexity.
Boundaries should correspond to meaningful dependency, ownership, environment, or build boundaries.
Public surfaces matter
When one referenced project consumes another through generated declarations, accidental exports become part of the dependency surface.
This encourages deliberate package APIs.
Runtime package resolution still matters
Project references help TypeScript understand compilation dependencies. They do not automatically configure a package manager, bundler, or runtime to resolve workspace packages correctly.
Build architecture must align TypeScript references with the actual deployment and packaging system.
Code Example
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist"
}
}
// packages/api/tsconfig.json
{
"compilerOptions": {
"composite": true,
"outDir": "dist"
},
"references": [
{
"path": "../shared"
}
]
}
// Build dependency graph:
//
// tsc -b packages/apiCommon Interview Pitfalls
- Putting unrelated runtime environments into one large TypeScript project without considering separate configuration needs.
- Creating project references without enabling the required composite project behavior.
- Building referenced projects manually in an incorrect dependency order.
- Assuming project references automatically configure runtime workspace resolution.
- Splitting every folder into a separate TypeScript project without a meaningful architectural boundary.
- Exposing large internal APIs between projects rather than defining intentional package surfaces.
- Assuming project references guarantee faster builds for every possible repository structure.
- Ignoring generated declaration boundaries between referenced projects.
How would you design TypeScript module, compiler, package, and project boundaries for a large monorepo containing applications and reusable libraries?
Direct Answer
Align each TypeScript project with its runtime, expose deliberate package APIs, model dependencies explicitly, generate reliable declarations for libraries, and keep compiler and runtime module resolution consistent.
Detailed Explanation
Large TypeScript repositories need boundaries at several different levels:
Treating the entire repository as one undifferentiated TypeScript project often hides important differences between those concerns.
1. Identify runtime environments
A browser application, Node.js service, worker, test environment, and reusable library may have different:
Do not automatically force all of them through one identical tsconfig.json.
Shared base configuration can reduce duplication, but environment-specific projects should be able to define their own valid assumptions.
2. Match module configuration to the host
For each project, decide what actually resolves imports:
Then configure TypeScript to model those rules.
The objective is not to select the most fashionable moduleResolution value. The objective is compile-time/runtime agreement.
3. Define package public APIs
Reusable packages should expose intentional entry points.
Avoid consumers importing arbitrary implementation files such as:
`text
@company/data/src/internal/cache/private-helper
A package export surface should make supported dependencies explicit.
Package exports can help enforce those boundaries at runtime and resolution level.
4. Distinguish type-only dependencies
Use import type when a dependency exists only in the type system.
This can make runtime dependency intent clearer and avoids accidentally relying on type-only declarations as runtime values.
5. Use project references where they represent real build boundaries
Large repositories can use references to model dependency ordering among TypeScript projects.
For example:
`text
shared-contracts
↓
domain-library
↓
backend-service
Keep the graph understandable and avoid circular project relationships.
6. Design declarations as public contracts
Reusable TypeScript libraries should expose declaration output that accurately represents their supported API.
Do not leak large private implementation types into public signatures accidentally.
If a public function's inferred type includes internal helper structures, consider whether an explicit exported contract would create a more stable API.
7. Separate source aliases from package contracts
A paths alias is a TypeScript resolution configuration feature. It should not become a hidden replacement for actual package/runtime configuration.
If a monorepo import crosses a package boundary, prefer a real package dependency and supported package entry point where appropriate.
8. Prevent environment leakage
A shared package should not accidentally depend on browser globals if it must also run on a server.
Separate environment-specific adapters from portable domain logic.
Compiler lib configuration and project boundaries can help expose these accidental dependencies.
9. Decide who emits JavaScript
Some projects use tsc to emit JavaScript.
Others use TypeScript for type checking while a bundler or other compiler emits code.
Make this explicit.
Avoid duplicate build stages producing conflicting outputs from the same sources.
10. Treat strictness as a repository contract
A shared package compiled under weaker assumptions can export types into stricter consumers.
Aim for deliberate strictness policy rather than packages silently disabling checks to compile.
11. Optimize build performance after establishing boundaries
Use project references, incremental compilation, and build caching when repository scale justifies them.
Do not weaken type accuracy by default solely to improve compilation speed.
For example, skipLibCheck can reduce declaration-file checking time but explicitly trades away some type-system checking of declaration files.
That tradeoff should be understood rather than enabled blindly.
12. Test package consumption
A library can compile internally yet still publish broken declarations or unusable entry points.
Test representative consumers against the built package surface.
Verify:
13. Keep migration incremental
For a JavaScript-heavy repository, gradual TypeScript adoption can use JavaScript inclusion, declaration files, boundary typing, and project-by-project migration.
Do not create false safety by declaring legacy areas as broad any and assuming the migration is complete.
14. Review dependency direction
TypeScript project boundaries are most useful when dependency direction reflects architecture.
For example, core domain packages should not depend on application UI packages merely because an import path happens to be convenient.
15. Treat configuration as executable architecture
A large repository's tsconfig, package metadata, exports, declaration output, build graph, and runtime resolver collectively define how modules actually interact.
Review them as one system rather than debugging each configuration file independently.
Code Example
// packages/contracts/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"strict": true,
"outDir": "dist"
}
}
// apps/api/tsconfig.json
{
"compilerOptions": {
"composite": true,
"strict": true
},
"references": [
{
"path": "../../packages/contracts"
}
]
}
// Runtime package metadata and
// supported export entry points
// must remain aligned with these
// TypeScript project boundaries.Common Interview Pitfalls
- Using one tsconfig for browser, server, worker, and library code despite different runtime assumptions.
- Selecting module-resolution settings without identifying the actual runtime or bundler.
- Allowing consumers to import arbitrary internal package implementation paths.
- Using tsconfig paths as a substitute for properly configured runtime or package dependencies.
- Publishing declaration files that expose unstable internal helper types.
- Creating circular project-reference dependencies between packages.
- Allowing shared packages to depend accidentally on environment-specific globals.
- Running several emit pipelines over the same source without defining which output is authoritative.
- Enabling skipLibCheck purely for speed without understanding the lost declaration-file checking.
- Assuming a library is valid because it compiles without testing its built consumer-facing package.
How should TypeScript types be used to design clear input, output, and domain API contracts?
Direct Answer
Define intentional boundary types for what callers may provide and receive, model optionality precisely, and avoid exposing persistence or implementation structures accidentally.
Detailed Explanation
A TypeScript API contract should describe what callers are allowed to provide and what they can safely expect in return.
Input contracts
Define inputs around the operation being performed rather than automatically reusing one large entity type.
For example, creating a candidate may require:
`typescript
type CreateCandidateInput = {
name: string;
email: string;
};
while the stored entity may contain additional fields:
`typescript
type Candidate = {
id: string;
name: string;
email: string;
createdAt: Date;
};
The caller should not need to supply database-generated fields merely because those fields exist on the stored model.
Output contracts
Return only the data the caller is intended to depend upon.
A public API that returns an enormous internal object creates accidental coupling because consumers may start depending on fields that were never intended to be stable.
Optionality
Represent absence deliberately.
These contracts mean different things:
`typescript
headline?: string
and:
`typescript
headline: string | null
The correct representation depends on the domain and transport semantics.
Domain versus persistence models
A database row is not automatically the ideal public API model.
Persistence structures may contain:
Translate them when the public contract has different semantics.
Return failure states intentionally
If an operation has expected outcomes such as not-found or conflict, consider representing those outcomes explicitly instead of forcing callers to infer them from nullable fields or arbitrary exceptions.
Avoid broad contracts
Types such as:
`typescript
Record<string, any>
provide very little useful contract information.
Use the narrowest stable shape that accurately represents the API.
TypeScript is most valuable when the contract itself teaches consumers how the operation should be used.
Code Example
type CreateCandidateInput = {
name: string;
email: string;
};
type CandidateSummary = {
id: string;
name: string;
};
type CreateCandidateResult =
| {
kind: 'created';
candidate: CandidateSummary;
}
| {
kind: 'email-conflict';
};
async function createCandidate(
input: CreateCandidateInput
): Promise<CreateCandidateResult> {
// implementation
throw new Error('example');
}Common Interview Pitfalls
- Using database entity types directly as every application API contract.
- Allowing callers to provide server-generated fields unnecessarily.
- Returning large internal objects and accidentally creating public dependencies on implementation details.
- Using optional properties without defining what absence means.
- Using any-based maps instead of declaring meaningful API structures.
- Representing several expected outcomes through ambiguous nullable values.
- Deriving every request type mechanically from a persistence model.
- Changing public types casually without considering downstream consumers.
Why are TypeScript types not enough to validate HTTP, JSON, database, or other external data at runtime?
Direct Answer
TypeScript checks source code statically and ordinarily erases types during compilation, so external runtime values must still be parsed or validated before becoming trusted domain data.
Detailed Explanation
TypeScript provides compile-time guarantees about values according to the information available to the compiler.
It does not automatically inspect every value entering the running JavaScript application.
Type erasure
Ordinary TypeScript type information is erased during compilation.
For example:
`typescript
interface Candidate {
id: string;
name: string;
}
creates no automatic JavaScript validator.
Unsafe assertion
This code changes TypeScript's belief but does not validate the JSON:
`typescript
const candidate =
JSON.parse(text) as Candidate;
If the JSON contains:
`json
{
"id": 123,
"name": null
}
TypeScript cannot retroactively guarantee the runtime structure merely because the value was asserted as Candidate.
Trust boundaries
Examples include:
Values crossing these boundaries should be treated according to how trustworthy their runtime source actually is.
unknown
unknown can be useful because it prevents code from performing type-specific operations until the value has been narrowed.
However, unknown itself does not validate anything.
Boundary validation
A healthy architecture commonly follows:
`text
external value
↓
unknown/untrusted representation
↓
runtime validation or parsing
↓
trusted domain value
After validation, downstream code can use precise domain types without repeated assertions.
Static plus runtime safety
TypeScript and runtime validation solve complementary problems.
Static checking helps developers compose the program correctly. Runtime validation determines whether real external data satisfies those assumptions.
Code Example
type Candidate = {
id: string;
name: string;
};
function parseCandidate(
value: unknown
): Candidate {
if (
typeof value !== 'object' ||
value === null
) {
throw new Error(
'Invalid candidate'
);
}
const record =
value as Record<
string,
unknown
>;
if (
typeof record.id !== 'string' ||
typeof record.name !== 'string'
) {
throw new Error(
'Invalid candidate'
);
}
return {
id: record.id,
name: record.name,
};
}Common Interview Pitfalls
- Casting parsed JSON directly into a trusted domain type.
- Assuming interfaces generate runtime validators.
- Treating unknown as though it automatically validates a value.
- Using any at external boundaries and allowing unsafe assumptions to spread.
- Repeating type assertions throughout domain logic instead of validating once.
- Assuming API documentation guarantees every real response follows the declared interface.
- Confusing static compiler guarantees with runtime security validation.
- Letting transport-specific uncertainty leak through the entire application.
How should a TypeScript library design and evolve its public API and declaration files without exposing unstable implementation details?
Direct Answer
Export deliberate contracts, generate accurate declaration files, keep implementation-only types private, and treat public type changes as API changes that can affect consumers.
Detailed Explanation
A reusable TypeScript library has both a runtime JavaScript API and a type-level API consumed by TypeScript users.
Both must evolve deliberately.
Public declarations
Declaration files describe the type surface consumers can see.
Only types associated with the public API need to become part of that supported contract.
Internal helper types should remain internal when consumers do not need them.
Avoid accidental inferred exports
An exported function can acquire a complicated inferred return type containing internal details.
For an important stable library API, an explicit exported contract can make the supported shape clearer.
`typescript
export interface CandidateSummary {
id: string;
name: string;
}
export function loadCandidate(
id: string
): Promise<CandidateSummary> {
// implementation
}
Consumers now depend on CandidateSummary, not on whichever internal implementation objects happen to flow through the function today.
Runtime and declaration alignment
A declaration must describe actual JavaScript behavior.
If the declaration says a value is always present while the runtime can return undefined, the library has created false safety for consumers.
API compatibility
Type-level changes can break callers even when emitted runtime JavaScript changes very little.
Examples include:
Therefore type declarations are part of versioned API design.
Package declarations with the package
Libraries can generate and publish declarations with their runtime package.
Consumers should resolve declarations corresponding to supported package entry points.
Do not expose internal module paths
Consumers should import supported package surfaces rather than deep private implementation paths.
This makes internal refactoring possible without unnecessarily breaking consumers.
Compiler-version compatibility
Highly sophisticated type syntax can increase the minimum TypeScript version required by consumers.
Use advanced features when they provide meaningful API value, and document supported compiler versions for widely consumed libraries.
The public type surface should optimize for correctness, stability, and usability rather than showing how advanced the implementation's type programming can be.
Code Example
// Public API
export interface CandidateSummary {
id: string;
name: string;
}
export interface CandidateClient {
getById(
id: string
): Promise<
CandidateSummary | undefined
>;
}
// Internal persistence or cache
// structures are intentionally not
// exported.Common Interview Pitfalls
- Publishing internal implementation types as part of the supported public API accidentally.
- Assuming type-only API changes cannot break consumers.
- Generating declarations that do not accurately represent runtime behavior.
- Allowing consumers to depend on deep internal module paths.
- Using complex new type syntax without considering supported consumer compiler versions.
- Changing generic constraints without considering compatibility.
- Removing exported types casually because they have no runtime representation.
- Treating generated declaration files as irrelevant build artifacts.
How can stricter TypeScript compiler options expose hidden assumptions in large application type models?
Direct Answer
Strict compiler options make implicit assumptions about absence and indexed access visible, helping teams model undefined values and optional properties more deliberately.
Detailed Explanation
Compiler strictness affects the assumptions developers are permitted to make throughout a TypeScript project.
Some options expose uncertainty that otherwise remains easy to overlook.
Optional property semantics
Consider:
`typescript
type Profile = {
headline?: string;
};
There is a semantic difference between:
undefinedexactOptionalPropertyTypes makes TypeScript model optional-property assignment more closely according to the declared property contract instead of automatically treating explicit undefined as interchangeable with absence unless the type allows it.
This can matter when object-presence checks, serialization, defaults, or patch semantics distinguish those states.
Indexed access uncertainty
Consider:
`typescript
const labels: Record<string, string> = {};
const label = labels[userInput];
An index signature describes values associated with keys but does not mean every possible runtime string is necessarily present in an actual object.
noUncheckedIndexedAccess adds undefined to accesses involving undeclared keys under supported index-signature scenarios, forcing code to handle possible absence more explicitly.
Strictness exposes model assumptions
These options can reveal designs that previously relied on assumptions such as:
Those may or may not be valid domain assumptions.
Do not enable flags blindly without migration planning
In a large established codebase, stricter flags may expose many existing assumptions at once.
A migration should:
1. Understand what guarantee the option adds
2. Categorize failures
3. Fix actual model problems
4. Avoid widespread unsafe assertions merely to get back to green
Do not weaken meaningful types globally
Changing:
`typescript
string
to:
`typescript
string | undefined
everywhere simply to satisfy the compiler can propagate uncertainty farther than necessary.
Instead, handle uncertainty where it genuinely enters.
Strict options are valuable when they improve the honesty of the application's model.
Code Example
type CandidateMap =
Record<string, Candidate>;
declare const candidates:
CandidateMap;
declare const candidateId:
string;
const candidate =
candidates[candidateId];
if (candidate) {
console.log(
candidate.name
);
}
type Profile = {
headline?: string;
};Common Interview Pitfalls
- Enabling strict options and then silencing every new error with assertions.
- Treating an arbitrary index-signature lookup as guaranteed to return a value.
- Assuming property absence and explicit undefined always have identical semantics.
- Making large parts of the domain nullable merely to satisfy stricter checking.
- Disabling strict compiler behavior when it reveals a real model bug.
- Migrating strictness without categorizing the kinds of assumptions being exposed.
- Using non-null assertions for every indexed access.
- Treating strict compiler settings as style-only preferences with no architectural impact.
How should developers investigate TypeScript compiler performance and test important compile-time API contracts?
Direct Answer
Measure compiler behavior with TypeScript diagnostics and traces, simplify unnecessarily expensive type relationships, and verify both accepted and intentionally rejected API usage.
Detailed Explanation
Type-system performance becomes an engineering concern when large projects or sophisticated library types noticeably slow type checking, builds, or editor feedback.
Do not guess that TypeScript is slow because one file looks complicated. Measure it.
Compiler diagnostics
TypeScript exposes compiler diagnostics options that can report information about compilation performance.
For deeper investigation, additional diagnostics and tracing capabilities are available.
These can help identify whether a problem involves:
Reduce unnecessary complexity
Common architectural improvements can include:
Do not simplify a type by replacing everything with any; compiler performance should not be bought by silently discarding important guarantees.
Public API annotations
For libraries, explicit public types can improve stability and make declaration generation more predictable.
Modern TypeScript also provides options related to isolated declaration generation for projects that deliberately annotate exported APIs. Use such features only when their build architecture benefits from them.
Compile-time contract testing
Runtime unit tests cannot verify that an invalid TypeScript call fails to compile.
Important reusable APIs should test examples such as:
`typescript
// should compile
get(candidate, "name");
// should fail
get(candidate, "missing");
The repository may use its existing type-test strategy or compiler expectations to verify these constraints.
Avoid brittle error-text tests
Compiler diagnostic wording can evolve.
Where practical, test the intended compile/fail contract rather than depending excessively on exact diagnostic prose.
Measure before optimization
A sophisticated conditional type used in one small library may be perfectly acceptable.
A similar type instantiated across thousands of application locations may become expensive.
Optimization decisions should follow real compiler measurements.
Code Example
type Candidate = {
id: string;
name: string;
};
function getProperty<
T,
K extends keyof T
>(
value: T,
key: K
): T[K] {
return value[key];
}
declare const candidate:
Candidate;
// Should compile.
getProperty(
candidate,
'name'
);
// A type-level test suite should
// also verify that unsupported
// keys are rejected.Common Interview Pitfalls
- Guessing at compiler bottlenecks without collecting TypeScript diagnostics.
- Replacing complex types with any solely to reduce compiler work.
- Creating deeply recursive conditional types with little consumer benefit.
- Ignoring editor responsiveness because command-line builds still complete.
- Testing only runtime behavior for libraries whose safety depends on compile-time rejection.
- Coupling tests excessively to exact compiler diagnostic wording.
- Optimizing type-system performance before confirming there is a measurable problem.
- Leaving extremely broad project scopes when clearer project boundaries exist.
How would you design TypeScript architecture and type governance for a large production platform with multiple teams, services, packages, and external integrations?
Direct Answer
Establish explicit trust and package boundaries, stable public contracts, strict compiler policy, controlled dependency direction, runtime validation, type tests, and measurable compiler performance.
Detailed Explanation
Large-scale TypeScript architecture is not primarily about writing the most advanced possible types.
It is about making assumptions, ownership, dependency boundaries, and runtime uncertainty visible across many teams.
1. Define trust boundaries
Identify where untrusted values enter:
Validate those values before they become trusted domain objects.
Do not make application-wide safety depend on assertions such as:
`typescript
response as Candidate
2. Separate transport, domain, and persistence contracts
These models can overlap but serve different responsibilities.
A persistence object may contain nullable storage fields, a transport object may contain string dates, and a domain object may require validated Date instances and stronger invariants.
Do not force one universal interface to represent all layers merely to reduce type declarations.
3. Establish strictness policy
Define which compiler guarantees are expected across production packages.
Avoid individual packages silently weakening important settings just to compile.
When stricter settings are adopted later, migrate intentionally rather than covering errors with as and non-null assertions.
4. Create meaningful package boundaries
Packages should correspond to real concerns such as:
Do not create circular dependencies between high-level and low-level modules.
A core domain package should not depend on a React application simply because one useful helper happened to be created there first.
5. Keep public APIs small
Export only supported contracts.
Prevent consumers from depending on package internals through uncontrolled deep imports.
This preserves refactoring freedom.
6. Govern shared types carefully
A giant shared-types package can become a dumping ground and couple unrelated systems together.
Share contracts only when those systems genuinely share ownership of the same concept.
Two services exchanging a versioned API message may share that protocol contract, but their internal domain models do not automatically need to be identical.
7. Version external contracts
When several independently deployed applications communicate, changing a TypeScript interface in one repository does not update the runtime code already deployed elsewhere.
Compatibility remains a runtime distributed-systems problem.
Use backward-compatible evolution, explicit message/API versions, or coordinated migrations where necessary.
Static types inside one repository do not eliminate deployment compatibility requirements.
8. Keep runtime validation close to ingress
Convert untrusted transport values once, then operate on trusted domain values downstream.
Avoid spreading parsing and optionality concerns throughout every business function.
9. Use advanced types selectively
Mapped, conditional, template-literal, and recursive types can create excellent framework or library APIs.
Evaluate them on:
If a normal explicit type communicates the same business rule more clearly, use it.
10. Review escape hatches
Track or review concentrated uses of:
anyas!@ts-ignoreThese may be justified at carefully controlled integration boundaries but should not silently become normal domain code.
11. Test type contracts
Reusable packages should verify valid and invalid usage in addition to runtime behavior.
This is especially important for generics and public utility APIs whose primary value is compile-time correctness.
12. Measure TypeScript performance
When type checking or editor responsiveness becomes slow, use compiler diagnostics and traces.
Possible architectural responses include:
Do not turn off meaningful checking before identifying the actual bottleneck.
13. Manage TypeScript upgrades deliberately
Compiler releases can improve inference, add checks, introduce deprecations, or expose previously accepted unsound patterns.
Upgrade through automated tests, type checks, builds, and representative package-consumer validation.
Avoid depending on accidental compiler behavior that is not part of the intended language/API contract.
14. Keep runtime architecture independent from static optimism
TypeScript cannot guarantee:
The runtime system must still protect those concerns.
15. Optimize for team comprehension
The strongest type system is not useful if engineers routinely bypass it because nobody understands the public APIs.
Good TypeScript architecture makes common correct code easy to write, invalid code difficult to express, compiler errors understandable, and uncertainty concentrated at explicit boundaries.
Code Example
// Protocol layer
type CandidateResponseDto = {
id: string;
name: unknown;
createdAt: string;
};
// Domain layer
type Candidate = Readonly<{
id: string;
name: string;
createdAt: Date;
}>;
function parseCandidate(
dto: CandidateResponseDto
): Candidate {
if (
typeof dto.name !==
'string'
) {
throw new Error(
'Invalid candidate name'
);
}
const createdAt =
new Date(dto.createdAt);
if (
Number.isNaN(
createdAt.getTime()
)
) {
throw new Error(
'Invalid candidate date'
);
}
return {
id: dto.id,
name: dto.name,
createdAt,
};
}
// Downstream domain code works
// with Candidate rather than the
// transport representation.Common Interview Pitfalls
- Using one universal type for persistence, transport, domain, and UI state regardless of semantic differences.
- Treating shared type packages as dumping grounds for unrelated application models.
- Assuming matching TypeScript interfaces guarantee compatibility between independently deployed services.
- Allowing packages to weaken strict compiler settings individually without architectural review.
- Publishing package internals through deep imports and losing refactoring freedom.
- Using assertions broadly instead of validating external values at ingress boundaries.
- Introducing advanced conditional types whose complexity exceeds their safety benefit.
- Ignoring compiler performance until developers begin bypassing type checking.
- Treating TypeScript as runtime authorization or distributed-system validation.
- Upgrading compiler versions without running type, build, and package-consumer verification.
Want to tailer your resume for TypeScript Developer roles?
Import your resume, scan it for critical TypeScript Developer keywords, and compare it against ATS standards instantly.