Ch. 2 · TypeScript

TypeScript Literal Inference and Const Assertions

Why let and object properties widen "GET" to string, how as const keeps literals and readonly tuples, and what it does not do at runtime.

~5 min readbeginnerupdated Oct 7, 2026

Every TypeScript developer eventually writes code like request(url, opts.method), sees “Argument of type ‘string’ is not assignable to parameter of type ‘Method’”, and stares at method: "GET" wondering how a string literal stopped being one. The answer is literal widening. Once you know the rule, the fix is usually one keyword, and the same rule explains a family of beginner interview questions about let, const and as const.

What TypeScript infers for “GET”

Start with a function that only accepts two methods:

type Method = "GET" | "POST";
declare function request(url: string, method: Method): void;

const m1 = "GET";   // type "GET"
let m2 = "GET";     // type string

request("/a", m1);  // ok
request("/a", m2);  // error
TypeScript
lit1.ts(7,15): error TS2345: Argument of type 'string' is not assignable to parameter of type 'Method'.
Text

The value is identical. The difference is what could happen later. A const binding can never be reassigned, so its type can safely be the exact literal "GET". A let binding could be set to "DELETE" on the next line, so TypeScript infers the wider string to allow that. The inference is about what the variable is allowed to hold in future, not what it holds now.

There’s one subtlety worth knowing. A const initialised with a literal gets a “widening” literal type. Copy it into a let and it widens again:

const c = "GET";
let d = c;
request("/a", d); // error: d is string
TypeScript

If the const had an explicit annotation (const e: "GET" = "GET"), copying it into a let keeps "GET". Explicit annotations are never widened. You won’t need this often, but it explains the occasional puzzling error in a refactor.

Objects widen their properties

Object literals behave like let, property by property:

const opts = { url: "/a", method: "GET" }; // { url: string; method: string }
request(opts.url, opts.method);
TypeScript
lit1.ts(10,19): error TS2345: Argument of type 'string' is not assignable to parameter of type 'Method'.
Text

The const only stops you reassigning opts. It doesn’t stop opts.method = "PATCH", so the property has to be string. This is the version of the problem that trips people in real code, because the object is often built in one place and passed to a typed function somewhere else.

There are three fixes, and choosing between them is a reasonable interview conversation.

Annotate the object. const opts: { url: string; method: Method } = { url: "/a", method: "GET" } checks the literal against the type immediately, and a typo like "GTE" fails right there. Use this when a named type already exists, which is most of the time.

Pass the literal directly. request("/a", "GET") never widens, because the literal is checked against the parameter type in context. Contextual typing is why inline objects passed straight into a typed function usually just work.

Use as const. That’s the next section.

What as const changes

A const assertion tells the compiler to infer the narrowest type it can for the whole expression:

const optsConst = { url: "/a", method: "GET" } as const;
// { readonly url: "/a"; readonly method: "GET" }

request(optsConst.url, optsConst.method); // ok
optsConst.method = "POST";
TypeScript
lit1.ts(14,11): error TS2540: Cannot assign to 'method' because it is a read-only property.
Text

Three things happen at once, recursively through nested objects and arrays. String, number and boolean literals keep their literal types. Every property becomes readonly. Arrays become readonly tuples with a fixed length. The readonly part is what makes the literal types safe: since you can’t change method, it can honestly stay "GET".

Arrays show the difference most clearly:

const pair = [10, "ten"];               // (string | number)[]
const pairConst = [10, "ten"] as const; // readonly [10, "ten"]
const n: number = pair[0];
TypeScript
lit1.ts(25,7): error TS2322: Type 'string | number' is not assignable to type 'number'.
  Type 'string' is not assignable to type 'number'.
Text

Without the assertion, TypeScript sees a mutable array that might gain more elements of either type, so every position is string | number. With it, position 0 is 10 and position 1 is "ten".

You can also apply as const to a single value, as in let g = "GET" as const, which gives a let the literal type "GET". That’s rarely useful, since a let you never reassign might as well be a const.

Deriving a union from a list

The most practical use of as const is keeping a runtime list and a type in sync. You often need both: an array to render a dropdown or validate input, and a type for function parameters.

const SIZES = ["sm", "md", "lg"] as const;
type Size = (typeof SIZES)[number]; // "sm" | "md" | "lg"

const s: Size = "xl";
TypeScript
lit1.ts(21,7): error TS2322: Type '"xl"' is not assignable to type '"lg" | "md" | "sm"'.
Text

typeof SIZES is readonly ["sm", "md", "lg"], and indexing it with number gives the union of its elements. Add "xl" to the array and the type updates. Without as const, typeof SIZES would be string[] and Size would be plain string, which is a mistake that compiles without any warning, so it’s worth checking with a hover in the editor. This pattern is a lightweight alternative to an enum and is common in codebases that avoid enums.

Since TypeScript 5.0, a generic function can request the same narrow inference from its callers with a const type parameter, function pick<const T>(...). That’s covered in the const type parameters note.

What as const doesn’t do

It’s a compile-time instruction only. The emitted JavaScript for { retries: 3 } as const is just { retries: 3 }. Run that emitted code with a mutation, which any caller holding a looser type could perform, and the object changes without complaint:

node -e 'const cfg = { retries: 3 }; cfg.retries = 5; console.log(cfg.retries, Object.isFrozen(cfg));'
# 5 false
Terminal

Any code that receives the object through a looser type, or plain JavaScript in a dependency, can still change it. If you need runtime immutability, call Object.freeze as well. Object.freeze is shallow, so nested objects need their own freeze, and TypeScript types the result of Object.freeze with readonly properties, so the two work together.

The other misconception is that as const validates anything. It narrows the type to exactly what you wrote. If what you wrote is wrong, say method: "GTE", the assertion happily produces "GTE", and you only find out when the value reaches request. Pairing it with satisfies, as in { ... } as const satisfies Options, checks the shape at the point of definition while keeping the narrow types. See the satisfies note for that combination.

Interview practice

A teammate writes const ROUTES = { home: "/", settings: "/settings" } and a function go(path: "/" | "/settings"). Calling go(ROUTES.home) fails. They propose changing the parameter to string. What do you suggest instead, and why?

Answer

The call fails because object properties widen, so ROUTES.home is string, not "/". Changing the parameter to string would make the error disappear by throwing away the check, so go("/setings") would compile again. I’d add as const to the object, making ROUTES.home the literal "/". Then I’d derive the parameter type from the object so the two can’t drift: type Path = (typeof ROUTES)[keyof typeof ROUTES], which is "/" | "/settings", and declare go(path: Path). Adding a route to the object now adds it to the type. If the team wants a typo in the object itself to be caught too, adding satisfies Record<string, `/${string}`> after as const checks that every value starts with a slash without widening anything.

Further reading

More in TypeScript

read ✓TypeScript · mid

TypeScript Enums vs Union Types

What TypeScript enums compile to, why string enums reject plain strings from JSON, and when an as const object beats both options.

~6 min readread →
esc