cswork / libraryOpen practice workspace

Articles

TypeScript Variance Interview Questions: Functions, Generics, and Assignability

Master TypeScript variance interview questions with strict function assignments, method exceptions, generic producers and consumers, and compiler checks.

TypeScript variance interview questions become easier when you stop memorizing arrow directions and ask what a caller is allowed to do. A function that promises to handle any event cannot be replaced by one that only understands mouse clicks. A function that produces a click event can satisfy a promise to produce a general event. Inputs and outputs create different substitution requirements, and TypeScript adds practical exceptions that you should recognize rather than mistake for a proof of safety.

This guide follows a small event-handler API under strict function checks. You will predict assignments, inspect the method-syntax exception, and repair an API that accepts an overly narrow callback. The same reasoning appears in generic containers, React callbacks, and library interfaces. For broader type-system context, see TypeScript Interview Questions for Senior Frontend Engineers.

Evidence boundary: Official facts come from the TypeScript Handbook, TSConfig documentation, and release notes. Candidate reports: no employer-specific report establishes this exact exercise. Inference covers the event API and the recommended interview workflow. The accompanying compiler fixture records its compiler version and strict options; assignability claims should be checked with the settings used by your actual project.

Which way can a function parameter vary?

Start with two structurally related types:

type AppEvent = { id: string };
type ClickEvent = AppEvent & { x: number; y: number };
type Handler<T> = (event: T) => void;

declare const handleAny: Handler<AppEvent>;
declare const handleClick: Handler<ClickEvent>;

const forClicks: Handler<ClickEvent> = handleAny; // OK
// @ts-expect-error: a general event may lack x and y
const forAny: Handler<AppEvent> = handleClick;

A caller of forAny is entitled to pass { id: "evt-1" }. If the assigned implementation accesses x, that promise is broken. In contrast, handleAny can accept every click event because each click event has the required id. The implementation accepts a wider input set than the specific callback slot needs, which is safe for this example.

Under strictFunctionTypes, function parameter types are checked contravariantly for the relevant function-type declarations. If ClickEvent is assignable to AppEvent, the corresponding handler relationship runs in the opposite direction. Explain that relationship through a legal call rather than saying only “contravariant” and expecting the term to carry the argument.

The @ts-expect-error comment is part of the exercise, not a production workaround. It asks the compiler to confirm that the next line has an error. If a later type change makes the assignment acceptable, the unused directive becomes a diagnostic. That makes negative assignments useful executable documentation of the intended API boundary.

Why do return values go in the other direction?

A producer promises to return a value. If a caller only needs an AppEvent, returning a ClickEvent satisfies that requirement. The extra coordinates do not invalidate the required ID. Replacing a click producer with a general-event producer would be wrong because the caller may rely on coordinates that are no longer present.

type Producer<T> = () => T;
declare const makeClick: Producer<ClickEvent>;
declare const makeAny: Producer<AppEvent>;

const eventSource: Producer<AppEvent> = makeClick; // OK
// @ts-expect-error: coordinates are not guaranteed
const clickSource: Producer<ClickEvent> = makeAny;

The type-compatibility handbook explains structural compatibility and function comparison. In this simple producer, the generic parameter is covariant: the producer follows the subtype direction of its output. Do not generalize from the word “function” alone. One function can use a type parameter in both its inputs and outputs, creating a different relationship.

AssignmentUnder strict function checksCaller-based explanation
General handler into click-handler slotAllowedIt accepts every click
Click-only handler into general-handler slotRejectedCaller may omit coordinates
Click producer into general-event sourceAllowedReturned click includes the required ID
General producer into click sourceRejectedCaller expects coordinates

This matrix is the first useful interview artifact. Cover the middle column and predict each result. Then invent one legal call that would break every rejected assignment. A concrete counterexample shows you understand substitution instead of merely recalling terminology.

Why does method syntax change the answer?

TypeScript intentionally treats method and constructor parameter positions differently from ordinary function-type parameter positions. The TypeScript 2.6 release notes explain the method exception introduced with strict function checks. That historical source describes the rule's origin; the compiler fixture verifies the behavior used in this article.

interface MethodSink<T> {
  accept(event: T): void;
}
interface PropertySink<T> {
  accept: (event: T) => void;
}

declare const clickMethod: MethodSink<ClickEvent>;
const broadMethod: MethodSink<AppEvent> = clickMethod; // Accepted

declare const clickProperty: PropertySink<ClickEvent>;
// @ts-expect-error under strictFunctionTypes
const broadProperty: PropertySink<AppEvent> = clickProperty;

These declarations look almost interchangeable, but the parameter-checking behavior is different. The accepted method assignment can expose a click-only implementation to a general event. It is therefore misleading to say that every accepted TypeScript program is sound or that strict mode removes every unsafe callback relationship. The language makes compatibility trade-offs, and an interview answer should name the relevant one.

For a callback boundary you control, a function-valued property often communicates and enforces the desired input constraint more clearly. That is an API-design choice, not a recommendation to mechanically rewrite every method in a codebase. Existing class hierarchies and library APIs may depend on method behavior. Examine the actual declaration and a failing call before changing syntax.

When does a generic become invariant?

A type parameter used for both consumption and production can need to stay fixed. Consider a mutable slot with function-valued operations:

type Slot<T> = {
  read: () => T;
  write: (value: T) => void;
};

declare const clickSlot: Slot<ClickEvent>;
declare const eventSlot: Slot<AppEvent>;
// @ts-expect-error: would allow writing a non-click
const a: Slot<AppEvent> = clickSlot;
// @ts-expect-error: read may return a non-click
const b: Slot<ClickEvent> = eventSlot;

The first assignment fails through the input side; the second fails through the output side. In this relationship, the slot is invariant in T. The important qualifier is the shape of this API. A superficially similar interface using a method for write can interact with the method exception. Do not infer invariance from the English name “mutable container” without checking its declared members.

A read-only view can expose just the producer side and permit a useful widening assignment. This is often an elegant repair when a consumer only needs to inspect values. It narrows the capability being promised instead of using a cast to conceal an incompatible write operation. The trade-off is that callers lose mutation through that view, which may be exactly the intended contract.

Also distinguish a read-only type view from deep runtime immutability. TypeScript types are erased, and a different alias may still mutate an object. Variance tells you about assignability of the declared shape; it does not freeze the underlying JavaScript object or validate data arriving from a server.

Can in and out annotations force the answer?

The generics handbook documents variance annotations and cautions that they are an advanced feature. For an appropriate generic declaration, out T describes production, in T describes consumption, and in out T expresses invariance. These annotations should match the structural behavior you intend; they are not a tool for making an unsafe assignment safe.

The handbook also distinguishes instantiation-based comparisons from structural comparisons. Variance annotations are consulted in the former setting and should not be relied on to change arbitrary structural compatibility. In most ordinary interview code, the compiler can infer the relationship from member usage. Reach for annotations only after you can explain that usage and why the annotation helps the API or compiler.

A related trap is a generic parameter that never appears in the structure. Two instantiations of an empty generic shape may be compatible because there is no member carrying the difference. If the application needs distinct currencies, IDs, or states, a meaningful discriminator or branding strategy may be required. Merely writing <T> in a type name does not create nominal separation.

How would you repair a typed event bus?

Suppose subscribe promises that a listener may receive any AppEvent, but an implementation passes a Handler<ClickEvent> because the developer expects only clicks today. One repair is to widen the listener's parameter and narrow inside it. Another is to change the subscription contract so the event key determines the callback's payload. Those are different APIs with different responsibilities.

For a discriminated union, a broad listener can examine kind before using click-only fields. For a keyed event map, subscribe<K extends keyof Events>(key: K, listener: (event: Events[K]) => void) expresses a relationship between the key and payload. The implementation must preserve that relationship when storing and dispatching listeners. A correct public signature does not excuse an internal cast that routes a keyboard payload to a click callback.

In an interview, ask whether listeners are registered for all events or a specific event kind. Then show the unsafe call the original contract permits. Finally, choose the repair that matches the product requirement and demonstrate both a permitted registration and a rejected mismatch. This is more persuasive than adding as any, which removes the evidence the exercise is asking you to reason about.

What do any, unknown, and compiler settings hide?

any can make an assignment appear to work by relaxing checking, so it is a poor witness in a variance demonstration. unknown requires narrowing before specific operations and often better represents unvalidated input. never represents an impossible value set, but clever examples involving it can obscure the simple caller contract. Establish the ordinary event relationship first, then explore these edge cases deliberately.

Record the exact compiler version and options. The fixture accompanying this article passed with TypeScript 7.0.2 using --strict --noEmit --ignoreConfig. Removing its expected-error directives produced the five predicted assignability diagnostics. A library consumer may have a different strictness setting, and the declaration form can matter even with the same flag. Reproducing a discrepancy requires the smallest complete type definitions, the assignment, and the compiler configuration.

Do not “test” only that the file compiles after all problematic lines are suppressed. Keep positive examples free of suppressions, and use @ts-expect-error only where rejection is the expected behavior. Remove one directive to inspect the actual diagnostic. The fixture should fail if the boundary disappears or if a supposedly safe assignment becomes invalid.

Sources and Further Reading