skip to content

This TypeScript code fails to compile: `declare function request(url: string, method: 'GET' | 'POST'): void; const config = { method: 'GET' }; request('/users', config.method);`. What type did the compiler infer for config.method, why, and how would you fix it?

level: middleimportance: must knowfreq 68%

answer

  1. the value is right, the type is not
  2. where it lands decides
  3. mutable slot, widened type
  4. const freezes the binding, not the property
  5. annotate, assert, or inline it

basics

~20 s

The compiler inferred string for config.method, because an object literal's property is a mutable location and its fresh literal type widens. Fix it by annotating the object with the union, adding as const, or passing 'GET' inline.

solid answer

~50 s

`config.method` is inferred as `string`, not `'GET'`. When you write a literal expression, the checker gives it a *fresh* literal type that may widen, and whether it widens depends on where the value lands. `config` is declared with `const`, but `config.method` is an ordinary mutable property — nothing stops `config.method = 'POST'` later — so the property is typed widely enough to allow that, which means `string`. And `string` is not assignable to `'GET' | 'POST'`, hence the error. Three fixes: annotate the object, `const config: { method: 'GET' | 'POST' } = { method: 'GET' }`, so the contextual type pins the property; write `const config = { method: 'GET' } as const`, which keeps the property's literal type and makes it readonly; or pass the literal inline at the call site, where the parameter's type provides context and no widening happens.

code

typescript · 11 lines
typescript
declare function request(url: string, method: 'GET' | 'POST'): void;

const config = { method: 'GET' };          // config.method: string
// @ts-expect-error Argument of type 'string' is not assignable to '"GET" | "POST"'.
request('/users', config.method);

const annotated: { method: 'GET' | 'POST' } = { method: 'GET' };
request('/users', annotated.method);       // ok, and still reassignable

const pinned = { method: 'GET' } as const; // { readonly method: 'GET' }
request('/users', pinned.method);          // ok, but now readonly

go deeper

for a junior

Recognise the error message and know the quickest correct fix: pass the literal directly at the call site, or annotate the object with the union type instead of casting the value.

for a middle

Explain the mechanism out loud — a literal expression gets a fresh literal type that widens in a mutable location, and a const binding does not make its properties immutable. Name all three fixes and their differences.

for a senior

Show how this bites during refactors, when inline arguments get hoisted into shared config, and choose deliberately between an annotated mutable shape and an as-const constant based on whether the value is meant to change.

for a principal

Set the house convention: where option objects are annotated at their declaration so widening never reaches a call site, and why blanket assertions to silence these errors are a code smell you review out.

## What actually got inferred Hover the two declarations and they differ in exactly the way that causes the error: ```ts const method = 'GET'; // type: 'GET' const config = { method: 'GET' }; // type: { method: string } ``` The second one is the whole question. The value written is identical; only the *place it lands* differs, and that place decides whether the literal type survives. ## Fresh literal types and widening When the checker types a literal expression such as `'GET'`, it does not immediately produce either `'GET'` or `string`. It produces a **fresh literal type** — the type `'GET'` carrying an internal flag that says "this came directly from a literal and is a candidate for widening". What happens next depends on the mutability of the target: - An **immutable** location keeps the literal type. A `const` binding of a primitive can never be reassigned, so `const method = 'GET'` is safely typed `'GET'`. - A **mutable** location widens the fresh type to its base primitive. `let method = 'GET'` is typed `string`, because the next line may legally assign a different string. An object literal's property is a mutable location. `const config` freezes the *binding* — you cannot write `config = somethingElse` — but it says nothing about the object's contents. `config.method = 'POST'` is perfectly legal JavaScript, so if the checker typed the property as `'GET'` it would have to reject that assignment, which would make ordinary mutable objects unusable. It widens instead, and the property becomes `string`. So the error message — `Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"'` — is accurate and slightly maddening: the value clearly *is* `'GET'`, but its **type** is `string`, and the compiler only reasons about types. ## Why widening is the right default It is tempting to call this a design mistake. It is not. Without widening, ordinary accumulation code would be unwritable: ```ts let name = ''; // if this were type '', the next line could never compile name = loadName(); ``` Every mutable slot initialised with a literal would be locked to that one value forever. Widening keeps mutable code natural, and the escape hatches below let you opt out precisely where you want the narrow type. ## The three fixes **1. Annotate the object (or the property).** An explicit annotation is a *contextual type*, and a contextual literal-union type stops the widening: ```ts const config: { method: 'GET' | 'POST' } = { method: 'GET' }; request('/users', config.method); // ok — property is 'GET' | 'POST' config.method = 'POST'; // still allowed, still checked ``` This is usually the best choice for a value you intend to keep mutating: the property stays writable, but only with members of the union, and the intent is documented in the source. **2. `as const`.** Appending `as const` to the object literal tells the checker to treat everything inside as immutable, so no property widens: ```ts const config = { method: 'GET' } as const; // { readonly method: 'GET' } request('/users', config.method); // ok ``` Note the trade: the property is now `readonly` *and* pinned to the single value `'GET'`, not the union. That is what you want for a constant, not for a value you plan to reassign. **3. Pass the literal where the context already has a type.** At a call site the parameter's declared type supplies the context, so nothing widens: ```ts request('/users', 'GET'); // fine — checked against 'GET' | 'POST' directly ``` This is why the bug so often appears only after a refactor: the call worked inline, someone hoisted the arguments into a config object, and the literal type evaporated on the way. ## The same trap in other shapes Inferred return types widen the same way, because a returned expression is not landing in an immutable slot: ```ts function getMethod() { return 'GET'; } // inferred: () => string ``` If a caller needs the literal type, annotate the return type as `'GET' | 'POST'` (or as `'GET'`). Array literals behave analogously: their elements are mutable, so a literal array of strings infers as `string[]`. ## Nothing here exists at runtime Widening is purely a checker decision. The emitted JavaScript is the same object either way, and the `as const` version is not frozen — `readonly` is a compile-time modifier, not `Object.freeze`. If you need the object to be genuinely immutable at runtime, that is a separate, explicit call. ## What an interviewer is listening for The weak answer is "TypeScript is being annoying, just cast it". The strong answer names the mechanism — a fresh literal type widens in a mutable location — explains why `const` on the binding does not reach the property, and then picks a fix on purpose: annotation when the value stays mutable, `as const` when it is genuinely a constant.

  • `config` is declared with `const` — why doesn't that keep the property's literal type?
    `const` constrains the binding, not the object. `config = other` is an error, but `config.method = 'POST'` is legal JavaScript, so the property is a mutable location and must be typed widely enough to permit that assignment. Only the *binding* being immutable justifies keeping a literal type, which is why a `const` primitive keeps `'GET'` and a property does not.
  • What type does `function getMethod() { return 'GET'; }` have, and why does that matter here?
    Its inferred return type is `() => string` — the returned literal widens for the same reason a property does. Callers therefore cannot pass the result into a `'GET' | 'POST'` parameter. Annotating the return type as `'GET' | 'POST'` (or `'GET'`) fixes it at the source rather than at every call site.
  • When would you prefer the annotation over `as const`?
    When the value is still meant to change. The annotation keeps the property writable while restricting it to the union's members, so `config.method = 'POST'` still compiles and is still checked. `as const` pins the property to the single value it was initialised with and marks it readonly, which is right for a constant and wrong for mutable config.

saying these in an interview costs you the question

  • Says const on the variable should have preserved the literal type
  • Reaches straight for `as any` or a cast to silence it
  • Thinks the object was mutated somewhere and the type followed
  • Believes readonly from as const freezes the object at runtime
  • Claims TypeScript infers the narrowest possible type everywhere

context