TS2339: Property does not exist on type - 12 Causes and Fixes in TypeScript
TS2339: Property does not exist on type means the compiler resolved the expression on the left of the dot to a type, searched that type for your property name, and did not find it. The runtime object may well have the property at execution time - TS2339 is a statement about the type, not about the data.
That single fact collapses the whole error into three possible moves: correct the name, correct the type declaration, or narrow the type you already have. Which one you need is written in the message itself, in the quotes after "on type".
Below are the 12 shapes this error actually takes in a real codebase, ordered by how often they show up, each with a reproduction, the fix, and a way to confirm it. If none of them match your case, jump to the diagnostic checklist.
| Error | error TS2339: Property 'foo' does not exist on type 'Bar'. |
|---|---|
| Where it happens | TypeScript - compile time only (tsc, ts-node, vite, webpack/ts-loader, Next.js, or the editor's TS server). Any framework, any runtime. |
| What it means | The type on the left of the dot has no member with that name, so the compiler refuses the property access. |
The Fast Fix
Read the type name printed after "on type" - it names the fix.
If it is your own interface and the property is legitimately part of the shape, declare it:
interface User {
id: string;
email: string;
displayName: string; // was missing -> TS2339 on user.displayName
}
If the message says on type 'Promise<User>' you forgot an await. If it says on type '{}' the value was inferred from an empty object literal. If it says on type 'never' you narrowed too far. If it lists a union, add a check on the discriminant before the access.
Then confirm with npx tsc --noEmit from the repo root, not just the editor.
What Is Actually Causing It
Jump to your case
- 1. Typo or wrong casing in the property name
- 2. The property is genuinely missing from your interface
- 3. Union type accessed without narrowing
- 4. Missing await - the value is still a Promise
- 5. Type inferred from an empty object literal
- 6. The value is typed 'object' instead of a real shape
- 7. Unconstrained generic type parameter
- 8. DOM query returns a type too generic for the property
- 9. Standard library method missing because 'lib' is too low
- 10. Custom global on window without declaration merging
- 11. Declaration file exists but is not part of the program
- 12. Over-narrowed to 'never' in an exhaustive branch
1. Typo or wrong casing in the property name
Reproduce it
interface User {
firstName: string;
}
const user: User = { firstName: "Ada" };
console.log(user.firstname);
// Property 'firstname' does not exist on type 'User'. Did you mean 'firstName'?
Why it happens — Property lookup in TypeScript is exact and case-sensitive. When the compiler finds a close match it reports the TS2551 "Did you mean" variant instead of plain TS2339, so the presence of a suggestion in the message is itself the diagnosis.
The fix
console.log(user.firstName);
Only the casing changed. Accept the editor's quick fix rather than retyping the name by hand.
Confirm it worked — npx tsc --noEmit is clean, and Ctrl/Cmd-clicking the property now jumps to its declaration in the interface.
2. The property is genuinely missing from your interface
Reproduce it
interface ApiUser {
id: string;
email: string;
}
async function load(): Promise<string> {
const res = await fetch("/api/me");
const user: ApiUser = await res.json();
return user.displayName;
// Property 'displayName' does not exist on type 'ApiUser'.
}
Why it happens — res.json() returns any, so the cast to ApiUser is unchecked and the hand-written interface drifted behind the API. The payload has displayName; the type does not.
The fix
interface ApiUser {
id: string;
email: string;
displayName?: string; // optional: the endpoint omits it for deleted users
}
return user.displayName ?? user.email;
The field is added, and marked optional because the server does not always send it - which forces the ?? fallback.
Confirm it worked — curl -s /api/me | jq 'keys' (or log Object.keys(user)) and compare the real keys against the interface. The type should be a superset of nothing and an exact match of what the endpoint documents.
3. Union type accessed without narrowing
Reproduce it
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
return Math.PI * shape.radius ** 2;
// Property 'radius' does not exist on type 'Shape'.
// Property 'radius' does not exist on type '{ kind: "square"; side: number; }'.
}
Why it happens — A union only exposes the members common to every branch. radius exists on one branch, so accessing it unguarded would be unsound for the other - note the second line of the message naming the guilty branch.
The fix
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
}
}
The switch on the discriminant kind narrows shape inside each branch, which is what makes radius legal.
Confirm it worked — Hover shape inside the case "circle" branch: it must read { kind: "circle"; radius: number }, not Shape. If it still reads Shape, your discriminant is not a literal type (e.g. it is typed string).
4. Missing await - the value is still a Promise
Reproduce it
interface User { name: string }
async function fetchUser(): Promise<User> {
return { name: "Ada" };
}
const user = fetchUser();
console.log(user.name);
// Property 'name' does not exist on type 'Promise<User>'.
Why it happens — fetchUser() evaluates to a promise object, whose type has then, catch and finally - not name. The type name inside the quotes tells you this outright.
The fix
const user = await fetchUser();
console.log(user.name);
One await. At module top level this requires ESM with top-level await, otherwise wrap the call in an async function.
Confirm it worked — Any TS2339 whose type reads Promise<...> is this cause and nothing else. Enable @typescript-eslint/no-floating-promises so the compiler catches the next one at the call site instead of at the property access.
5. Type inferred from an empty object literal
Reproduce it
const config = {};
config.port = 3000;
// Property 'port' does not exist on type '{}'.
Why it happens — TypeScript infers the type of a const once, at the initializer. An empty literal infers the empty type {}, and later assignments never widen it back.
The fix
interface Config {
port: number;
host: string;
}
const config: Config = { port: 3000, host: "0.0.0.0" };
Declare the shape and build the object in one expression. If the object really must be filled in stages, use const config: Partial<Config> = {}; and read with config.port ?? 8080.
Confirm it worked — Hover config: it must show Config, not {}. A type of {} or { [x: string]: never } in the hover always means inference ran against an empty initializer.
6. The value is typed 'object' instead of a real shape
Reproduce it
function logEvent(payload: object) {
console.log(payload.id);
// Property 'id' does not exist on type 'object'.
}
Why it happens — object means "any non-primitive" and declares zero members, so every property access on it fails. A value typed unknown produces a different diagnostic ("is of type 'unknown'") rather than TS2339, so seeing TS2339 here points specifically at object.
The fix
interface Event {
id: string;
name: string;
}
function logEvent(payload: Event) {
console.log(payload.id);
}
If the function is genuinely generic, constrain instead: function logEvent<T extends { id: string }>(payload: T). If the shape is truly unknown at that point, use Record<string, unknown> and bracket access (payload["id"]) plus a typeof check before using the value.
Confirm it worked — Hover the parameter - it must no longer read object. Grep the codebase for : object (rg ": object\b"); in application code it is almost always a placeholder someone never filled in.
More causes (6 remaining)
7. Unconstrained generic type parameter
Reproduce it
function getId<T>(item: T): string {
return item.id;
// Property 'id' does not exist on type 'T'.
}
Why it happens — An unconstrained T could be instantiated with number, null, or anything else, so the compiler must assume no members at all. It checks the generic body once against the constraint, not once per call site.
The fix
function getId<T extends { id: string }>(item: T): string {
return item.id;
}
The extends { id: string } constraint is the only line that changed, and it is what makes the body type-check.
Confirm it worked — Call it with a mismatched argument - getId(42) must now fail at the call site with an argument-assignability error. If it still compiles, your constraint was not applied.
8. DOM query returns a type too generic for the property
Reproduce it
const input = document.querySelector("#email");
input.value = "ada@example.com";
// Property 'value' does not exist on type 'Element'.
Why it happens — querySelector returns Element | null, and value is declared on HTMLInputElement, further down the DOM hierarchy. getElementById has the same problem one level up: it returns HTMLElement | null, which also has no value.
The fix
const input = document.querySelector<HTMLInputElement>("#email");
if (!input) throw new Error("#email not found");
input.value = "ada@example.com";
The generic argument picks the specific element type, and the null check satisfies strictNullChecks. Where the element can be of several kinds, prefer a runtime guard: if (el instanceof HTMLInputElement).
Confirm it worked — Hover input - it must read HTMLInputElement | null before the guard and HTMLInputElement after it. Keep an instanceof check in any code path where the selector could match a non-input, since the generic argument is an unchecked assertion.
9. Standard library method missing because 'lib' is too low
Reproduce it
// tsconfig.json
{ "compilerOptions": { "target": "ES2019", "lib": ["ES2019", "DOM"] } }
const last = [1, 2, 3].at(-1);
// Property 'at' does not exist on type 'number[]'.
Why it happens — Array methods are not built into the compiler; they come from the lib.*.d.ts files your lib setting loads. at is declared in the ES2022 array lib, so an ES2019 lib simply never sees it.
The fix
{ "compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM"] } }
Raise lib (and target, which supplies the default lib) to the version that declares the method. Only do this if your deployment runtime actually implements it - Array.prototype.at needs a modern browser or Node 16.6+. Otherwise use arr[arr.length - 1].
Confirm it worked — npx tsc --showConfig prints the effective lib array; npx tsc --listFiles should list a lib.es2022.*.d.ts under your TypeScript install. Both reflect the merged config, which is what the compiler used.
10. Custom global on window without declaration merging
Reproduce it
window.dataLayer.push({ event: "signup" });
// Property 'dataLayer' does not exist on type 'Window & typeof globalThis'.
Why it happens — Window is declared in lib.dom.d.ts and knows only the standard members. A third-party script that attaches dataLayer at runtime cannot change the compile-time type - you have to merge your member into the existing interface.
The fix
// types/global.d.ts
export {};
declare global {
interface Window {
dataLayer: Record<string, unknown>[];
}
}
window.dataLayer = window.dataLayer ?? [];
window.dataLayer.push({ event: "signup" });
The export {} makes the file a module, which is what lets declare global be legal there. The runtime guard matters because the type now lies if the external script has not loaded yet.
Confirm it worked — Hover window.dataLayer and confirm the declared type appears. If it does not, the declaration file is outside the program - see the next cause.
11. Declaration file exists but is not part of the program
Reproduce it
// tsconfig.json - types/ is not included
{ "include": ["src"] }
// types/express.d.ts exists and augments Request
app.get("/me", (req, res) => res.json(req.user));
// Property 'user' does not exist on type 'Request<...>'.
Why it happens — Declaration merging only applies to files inside the compilation. A .d.ts that include does not reach is invisible, so the augmentation never happens even though the file is correct.
The fix
{ "include": ["src", "types"] }
// types/express.d.ts
import type { AuthUser } from "../src/auth";
declare global {
namespace Express {
interface Request {
user?: AuthUser;
}
}
}
The tsconfig change is the fix; the declaration itself was already right.
Confirm it worked — npx tsc --listFiles must print types/express.d.ts. If the CLI is clean but your editor still errors, restart the TS server - it may be using a different tsconfig.
12. Over-narrowed to 'never' in an exhaustive branch
Reproduce it
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
default:
throw new Error(`Unknown shape: ${shape.kind}`);
// Property 'kind' does not exist on type 'never'.
}
}
Why it happens — Every branch of the union was handled, so in default the narrowed type is never - the type with no possible values and no members. This TS2339 is good news: it proves the switch is exhaustive.
The fix
default: {
const exhaustive: never = shape;
throw new Error(`Unknown shape: ${JSON.stringify(exhaustive)}`);
}
Assign to an explicitly never-typed local instead of reading a property off it. The same never appears from contradictory guards (if (typeof x === "string" && typeof x === "number")), where the fix is to delete the impossible condition.
Confirm it worked — Add a third variant to Shape. The const exhaustive: never = shape; line must now fail to compile, pointing at the case you forgot - that is the whole purpose of the pattern.
## None of Those? Narrow It Down
- Read the type name in quotes after "on type" before anything else.
Promise<...>means a missingawait;{}means inference from an empty literal;nevermeans over-narrowing;Element/HTMLElementmeans the DOM type is too generic; a bareTmeans an unconstrained generic; a|union means missing narrowing. Each of these rules out all the others. - Check whether the message carries a "Did you mean ...?" suggestion. If it does, it is a spelling or casing mistake and you are done; if it does not, the name is not close to anything in the type and the type itself is the problem.
- Hover the expression immediately left of the dot and compare its reported type against what you expected. If the hover shows a type you did not intend to be there, fix the inference or the annotation - not the property access.
- Log the runtime value (
console.log(Object.keys(value))). Property present at runtime but absent in the type means a stale or missing declaration; absent at runtime too means your code is wrong and the compiler just caught a real bug. - Run
npx tsc --noEmitfrom the repo root. If the CLI is clean and only the editor complains, the editor is using a different tsconfig or TypeScript version - restart the TS server and check the workspace TS version. - If the property should arrive from a
.d.tsor adeclare globalblock, runnpx tsc --listFilesand confirm that file is listed. Not listed meansinclude/files/typeRootsdoes not reach it, so the augmentation never ran. - If the property is a standard library member (
at,flat,Object.entries,structuredClone), runnpx tsc --showConfigand inspect the effectivelib. Alibbelow the version that declares the member is the cause. - If the property comes from a package, open
node_modules/<pkg>/**/*.d.tsornode_modules/@types/<pkg>and search for the name. Missing there means the installed types are a different major version from the docs you are reading - reconcile the versions rather than casting.
Why This Error Exists At All
TypeScript's types are erased. Nothing of the type system survives into the emitted JavaScript, so the compiler gets exactly one chance to tell you a property access is wrong: at compile time, from the declared or inferred type alone. It cannot look at the object you will actually have at runtime. That is why TS2339 is so often reported on code that works perfectly in production - the data has the property, the type does not, and only the type is checked.
The second design decision behind it is that object types are closed. An interface describes every member a value of that type is known to have; anything else is, as far as the checker is concerned, a mistake worth reporting rather than an open-ended possibility worth allowing. The same rule applied to a union means only the members shared by every branch are accessible, and applied to a generic parameter it means only the members guaranteed by the constraint. object, {}, and never are just the extreme cases of the same rule - types with almost no members, or none at all. One rule, many faces.
Once you see it that way, every TS2339 fix is a choice between changing the type and changing the access. You change the type when the declaration is wrong or incomplete: add the field, raise lib, merge into Window, constrain the generic. You change the access when the type is right and the code is reaching past what it guarantees: narrow the union, await the promise, guard the DOM node. What you should almost never reach for is as any or a bare cast, because those do not resolve the disagreement - they silence the one mechanism that would have told you which side was wrong, and hand you a runtime TypeError later instead.
Stop It From Coming Back
- Turn on
"strict": truein tsconfig. WithoutnoImplicitAny, parameters andJSON.parse/res.json()results drift intoany, which accepts every property access and defers the mismatch to a runtimeTypeErrorinstead of a TS2339 you can fix in seconds. - Generate API types instead of hand-writing them:
openapi-typescriptfrom an OpenAPI spec, GraphQL Code Generator from a schema, or Prisma's generated client. A hand-maintained interface will eventually fall behind the server, and that drift is the single most common source of this error. - Validate at the trust boundary with a schema library such as Zod or Valibot and derive the type from the schema (
z.infer<typeof UserSchema>). One declaration then serves as both the runtime check and the compile-time type, so they cannot disagree. - Enable
@typescript-eslint/no-floating-promisesand@typescript-eslint/no-misused-promises. Both catch a forgottenawaitat the call site, before it surfaces asProperty 'x' does not exist on type 'Promise<...>'. - Keep all global augmentation in one
types/global.d.tslisted explicitly in tsconfig'sinclude, and assert it stays in the program by checkingnpx tsc --listFilesin CI. Augmentations fail silently when the file drops out of the compilation. - Use the
const exhaustive: never = value;pattern in everydefaultbranch over a discriminated union, or enable@typescript-eslint/switch-exhaustiveness-check. Adding a union member then breaks the build at the exact spot that needs updating.
Related Guides
-
TS2345: Argument of type is not assignable to parameter of type
-
SettingWithCopyWarning: A value is trying to be set on a copy of a slice
Errors You Will Probably Hit Next
TS2551: Property 'firstname' does not exist on type 'User'. Did you mean 'firstName'?TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Config'.TS18047: 'input' is possibly 'null'.
The type name printed after "on type" is the diagnosis, not decoration - read it first and the fix is usually one line away. Then decide deliberately whether the declaration is wrong or the access is unsafe, because those have opposite fixes. Reaching for as any picks neither, and converts a compile error you can see into a runtime failure you cannot.
댓글
댓글 쓰기