The compiler rejected a property you are sure is there:
src/result.ts(6,19): error TS2339: Property 'data' does not exist on type 'ApiResult'.
Property 'data' does not exist on type '{ ok: false; error: string; }'.
src/form.ts(2,20): error TS2339: Property 'value' does not exist on type 'Element'.
src/track.ts(1,8): error TS2339: Property 'analytics' does not exist on type 'Window & typeof globalThis'.TS2339 means the type TypeScript has for the expression before the dot has no member with that name. It does not mean the property is missing at runtime; often it exists. The compiler only knows the declared or inferred type, and that type is either too wide (a union, Element, {}) or was never told about the property (a global added by a script tag). The fix is to give the compiler better information, not to switch the check off.
Quick fix checklist
- Read the type at the end of the message: that is what TypeScript thinks the value is.
- A union type (or an indented line naming one member): narrow first with a discriminant (
if (res.ok)),in, orinstanceof. ElementorHTMLElement: checkel instanceof HTMLInputElement(or another specific element class) before reading.value,.disabledor.checked.{}: you created an empty object and added properties later; declare its type when you create it.Window & typeof globalThis: augmentWindowwithdeclare globalin a.d.tsor module file.unknown(TS18046 instead of TS2339): validate the data with a type guard first.
Before you start
If the name is a near-miss, you get TS2551 instead, with a suggestion: Property 'emial' does not exist on type 'Person'. Did you mean 'email'?. The suggestion is not offered for every typo (a transposed nmae gets plain TS2339 in current versions), so compare spellings before anything else.
For DOM examples, your tsconfig.json must include the DOM library ("lib": ["es2022", "dom"] or a target whose default libs include it). The examples below were compiled with TypeScript 7.0 and 5.9 using strict: true; both print the same messages.
Why it happens
Property access is checked against the type of the expression, and different kinds of types answer “which properties do you have?” differently:
- Unions. For
A | B, you may only access a property that exists on both, because the compiler doesn’t know which member you have. The indented line names the member that lacks it. - Empty object literals.
const config = {}infers the type{}, which has no properties. Unlike arrays, object types never grow as you assign to them later. objectandunknown.objectmeans “any non-primitive” and exposes no properties.unknownexposes nothing at all and produces TS18046 ('body' is of type 'unknown').- DOM queries.
document.querySelector("input")returnsHTMLInputElement | nullbecause the lib maps tag names to element types. A selector like".email"or"#save"cannot be understood by the type system, so the return type falls back toElement | null.getElementByIdalways returnsHTMLElement | null, which lacksdisabledorvalue. - Globals.
windowis typed fromlib.dom.d.ts. A property added at runtime by a third-party script is invisible until you declare it.
Step-by-step walkthrough
Step 1: Reproduce and read the type name
type ApiResult =
| { ok: true; data: string[] }
| { ok: false; error: string };
export function show(res: ApiResult) {
console.log(res.data);
}The message says Property 'data' does not exist on type 'ApiResult', and the elaboration names { ok: false; error: string; } as the culprit. That tells you the fix: prove you are not in the failure branch.
Step 2: Narrow unions
export function show(res: ApiResult) {
if (res.ok) {
console.log(res.data);
} else {
console.error(res.error);
}
}
type Cat = { meow(): void };
type Dog = { bark(): void };
export function speak(pet: Cat | Dog) {
if ("meow" in pet) pet.meow();
else pet.bark();
}A shared literal property such as ok or kind (a discriminant) is the cleanest option because each branch gets a precise type. When the members have no shared tag, in narrows on the presence of a property, and instanceof narrows classes.
Step 3: Narrow DOM elements with instanceof
const field = document.querySelector(".email");
if (!(field instanceof HTMLInputElement)) {
throw new Error("Expected .email to be an <input>");
}
console.log(field.value);
const save = document.getElementById("save");
if (save instanceof HTMLButtonElement) save.disabled = true;The instanceof check also handles null, and it is a real runtime check: if someone changes the markup, you get a clear error instead of undefined flowing through your code. The generic form document.querySelector<HTMLInputElement>(".email") also compiles, but it is an unchecked assertion written as a type argument; use it only when the markup is generated by the same code.
Step 4: Declare globals properly
Create a declaration file, for example src/global.d.ts:
interface Analytics {
track(event: string, props?: Record<string, unknown>): void;
}
declare global {
interface Window {
analytics: Analytics;
}
}
export {};interface Window merges with the built-in declaration, so window.analytics.track("signup") now type-checks. The export {} makes the file a module, and declare global is how a module adds to the global scope. A frequent trap: writing interface Window { ... } without declare global in a file that has any import or export declares a new local interface that nothing uses, and the error stays. Make sure the file is covered by include in tsconfig.json.
Step 5: Type objects at creation and validate unknown data
interface Config { debug?: boolean; level?: "info" | "warn" }
const config: Config = {};
config.debug = true;
const flags: Record<string, boolean> = {};
flags.darkMode = true;Annotate the shape up front, or use Record<string, T> when keys really are open-ended. For data from JSON.parse or response.json(), those return any, which hides TS2339 completely (a worse problem). Assign the result to unknown and narrow with a type guard:
interface Profile { id: string; name: string }
function isProfile(value: unknown): value is Profile {
return (
typeof value === "object" && value !== null &&
"id" in value && typeof value.id === "string" &&
"name" in value && typeof value.name === "string"
);
}
export async function loadProfile(url: string): Promise<Profile> {
const body: unknown = await (await fetch(url)).json();
if (!isProfile(body)) throw new Error("Unexpected profile payload");
return body;
}Worked scenario
A marketing page tracks newsletter sign-ups. The original script:
const form = document.querySelector("#signup")!;
const email = form.querySelector(".email")!;
form.addEventListener("submit", (event) => {
event.preventDefault();
window.analytics.track("signup", { plan: email.value });
});src/signup.ts(6,50): error TS2339: Property 'value' does not exist on type 'Element'.Diagnosis: the ! removed null but the type is still Element, because ".email" is a class selector. (window.analytics passes only because global.d.ts from Step 4 is in the project; without it there would be a second TS2339.) Someone suggests (email as any).value. That compiles, but if a designer turns the input into a custom component, value is undefined and analytics silently records garbage.
const form = document.querySelector("#signup");
const email = form?.querySelector(".email");
if (!(form instanceof HTMLFormElement) || !(email instanceof HTMLInputElement)) {
throw new Error("Signup markup changed: expected form#signup containing input.email");
}
form.addEventListener("submit", (event) => {
event.preventDefault();
window.analytics.track("signup", { domain: email.value.split("@")[1] });
});The instanceof checks give both variables precise types, and because they are const, the narrowing survives into the event handler.
Common mistake
Casting to any ((res as any).data, (window as any).analytics) or adding // @ts-ignore above the line. Both remove the error and every other check on that expression, including typos and wrong argument types. A quieter version of the same mistake is adding [key: string]: any to an interface so any property name compiles; you lose the TS2551 spelling help forever.
Another tempting fix is an optional property on every union member (data?: string[] on the error branch too). It compiles, but now every consumer must handle undefined data even after checking ok, and the union no longer models the API.
Verify the behavior
npx tsc --noEmit should print nothing. To lock in the narrowing, add a type-level check that fails the build if someone removes the guard:
export function mustNarrow(res: ApiResult) {
// @ts-expect-error: data is only available after checking res.ok
res.data;
}For the DOM fix, open the page, submit the form and confirm the event in your analytics debugger; then rename the input’s class in DevTools and submit again to see the descriptive error rather than a silent undefined.
Interview exercise
Why does document.querySelector("input") return HTMLInputElement | null, while document.querySelector("#email") returns Element | null? How would you safely read .value from the second one?
Answer and reasoning
querySelector has overloads keyed by tag name through HTMLElementTagNameMap: when the argument is exactly a known tag such as "input", the return type is the matching element class. Any other string, including id and class selectors, falls back to the generic signature whose default is Element. The type system does not parse CSS selectors, and an id says nothing about which element type carries it.
To read .value safely, check el instanceof HTMLInputElement. That narrows the type and verifies the assumption at runtime. Passing a type argument such as querySelector<HTMLInputElement>("#email") is shorter but is only an assertion: it compiles even if the element is a div. A strong answer mentions the trade-off and why runtime checks belong at boundaries the code does not control, such as markup.