skip to content

Given `interface Logger { (message: string): void; level: number }` in TypeScript, why does `const logger: Logger = (message) => {}` fail to compile, and how do you build a conforming value without an `as` assertion?

level: seniorimportance: should knowfreq 32%

answer

  1. the annotation checks the initializer
  2. a bare arrow has no properties
  3. assign returns an intersection
  4. properties fold into an inferred function type
  5. an assertion checks nothing

basics

~20 s

An arrow function satisfies the call signature but has no level property, so the initializer is missing a member. Build the value with Object.assign, whose result type is the callable intersected with the properties, or assign properties onto a function declaration so the compiler folds them into its type.

solid answer

~50 s

The annotation is checked against the **initializer**, and an arrow function expression is callable but has no `level`, so the compiler reports the missing property. Two ways to fix it honestly. First, `Object.assign(fn, { level: 1 })` — its declared return type is the intersection of the target and the sources, so the result is callable *and* carries `level`, and the assignment checks. Second, declare the function with `function` (or a `const`-bound function expression) and assign the property afterwards; the compiler folds properties assigned in that scope into the function's inferred type, and the result is then assignable to `Logger`. The third option — `as Logger` on the bare arrow — is not a fix: an assertion performs no check and emits no code, so if you forget to attach `level`, reads of it return `undefined` at runtime with the type layer insisting otherwise.

code

typescript · 13 lines
typescript
interface Logger {
  (message: string): void;
  level: number;
}

const logger: Logger = Object.assign(
  (message: string): void => {
    if (logger.level > 0) console.log(message);
  },
  { level: 1 },
);

logger('checked, not asserted');

go deeper

for a junior

Understand that a value annotated with a callable-plus-properties type must have the properties too — a plain arrow function is only half of it.

for a middle

Explain that the annotation is checked at the initializer, and produce a conforming value with Object.assign or by assigning properties onto a function declaration.

for a senior

Argue why the assertion route is a defect: it removes the check while keeping the claim, so a missing property becomes a silent undefined far from its cause. Know the widening trap on literal-typed properties.

for a principal

Set the codebase rule — where assertions are permitted at all, how construction of hybrid values is confined to small factories with honest return types, and how that boundary is enforced in review.

## Why the obvious code is rejected ```ts interface Logger { (message: string): void; level: number; } const logger: Logger = (message) => {}; // Property 'level' is missing in type '(message: string) => void' ``` The annotation on a `const` is checked against the initializer at the point of declaration. The arrow function matches the call signature, and nothing else. Adding `logger.level = 1` on the next line does not help — the error is at the initializer, and by then `logger` is already required to have been a full `Logger`. This surprises people because the JavaScript would work fine: functions are objects, you can hang a property on one at any time. The type layer is stricter than the runtime here, deliberately: it wants the value to be complete when it claims the type. ## Fix one: `Object.assign` The standard library types `Object.assign` so that the result is the intersection of the target and the sources: ```ts const logger: Logger = Object.assign( (message: string): void => { if (logger.level > 0) console.log(message); }, { level: 1 }, ); ``` The expression's type is `((message: string) => void) & { level: number }`, which is assignable to `Logger`. Nothing is asserted; if you misspell `level` or give it a string, the assignment fails to compile — which is precisely the property you want. One sharp edge: an object literal widens its property types. If `Logger` declared `level: 'debug' | 'info'`, then `{ level: 'info' }` infers `level: string` and the assignment is rejected. Fix it by narrowing at the source — `{ level: 'info' as const }` — or by annotating the literal. ## Fix two: properties on a function declaration TypeScript folds properties assigned to a function declaration (or a `const`-bound function expression) into that function's inferred type, within the same scope: ```ts interface Counter { (label: string): number; count: number; } function tick(label: string): number { tick.count += 1; console.log(label, tick.count); return tick.count; } tick.count = 0; const counter: Counter = tick; // ok ``` Here `tick`'s inferred type already includes `count`, so the final assignment checks. The property's type comes from the assignment, so the same widening caveat applies: `tick.level = 'info'` infers `string`. This form reads well when the hybrid is a module-level singleton; `Object.assign` reads better when you are producing values in a factory. ## Why the assertion is the wrong answer ```ts const logger = ((message: string) => {}) as Logger; // compiles logger.level.toFixed(0); // runtime TypeError ``` `as` is an assertion, not a conversion. It performs no check, generates no code, and simply instructs the compiler to stop disagreeing. The whole point of the hybrid type was to guarantee that callers can read `level`; asserting deletes that guarantee while leaving the documentation in place, which is worse than having no type at all — the next reader trusts it. There is a narrow legitimate use: building the value in steps where the intermediate state genuinely cannot be expressed, ideally isolated inside one small factory function whose *return* type is the honest one, so the unchecked region is three lines rather than a whole module. ## Choosing between the two fixes - **Factory or per-instance value** → `Object.assign`, because it composes and its type falls out of inference. - **Module-level singleton with self-reference** → function declaration plus property assignment, because the function can refer to its own properties naturally and hoisting makes ordering forgiving. - **Value produced by a third-party call** → wrap it in a factory that returns the hybrid type, and keep any assertion inside. ## The runtime picture None of this exists after compilation. `Object.assign` is a real runtime call and really does attach the property; the function-declaration form emits a plain property assignment. The *interface* emits nothing, checks nothing, and defends nothing — it only decided, at compile time, whether your construction was honest. That asymmetry is exactly what an interviewer is probing with this question: can you produce a value that genuinely has the shape, rather than one the compiler has been told to stop asking about.

  • Why is `as Logger` on the bare arrow function dangerous here?
    Because `as` asserts rather than converts: no check runs and no code is emitted. The value has no `level`, so `logger.level` is `undefined` at runtime while the type says `number` — and `undefined > 0` quietly evaluates to `false` rather than throwing, so the bug surfaces far from its cause. The type is now a false document.
  • Suppose `level` is typed `'debug' | 'info'` instead of `number` — what changes with `Object.assign`?
    The object literal widens: `{ level: 'info' }` infers `level: string`, which is not assignable to the union, so the assignment is rejected. Narrow at the source with `{ level: 'info' as const }`, or annotate the literal so the property keeps its literal type. The same widening applies to properties assigned onto a function declaration.
  • Does adding `logger.level = 1` on the line after the declaration fix the original error?
    No. The annotation is checked against the initializer, so the error is reported where the value is created — before any later statement runs. The value must already be complete when it claims the type; a later assignment is a separate operation the declaration check knows nothing about.

saying these in an interview costs you the question

  • Says the arrow function satisfies the interface because it is callable
  • Reaches for `as Logger` and calls the error a TypeScript limitation
  • Thinks assigning the property on the next line fixes the initializer error
  • Assumes `Object.assign` returns only the target's type, losing the properties
  • Believes the interface guarantees at runtime that `level` exists

context