tsz_solver/narrowing_property.rs
1//! Property-based type narrowing.
2//!
3//! This module contains narrowing methods for:
4//! - `in` operator narrowing (property presence check)
5//! - Property type lookup for narrowing
6//! - Object-like type detection for instanceof support
7
8use crate::narrowing::NarrowingContext;
9use crate::types::{ObjectShapeId, PropertyInfo, TypeId, Visibility};
10use crate::visitor::{
11 intersection_list_id, object_shape_id, object_with_index_shape_id, type_param_info,
12 union_list_id,
13};
14use tracing::{Level, span, trace};
15use tsz_common::interner::Atom;
16
17impl<'a> NarrowingContext<'a> {
18 /// Check if a type is object-like (has object structure)
19 ///
20 /// This is used to determine if two types can form an intersection
21 /// for instanceof narrowing when they're not directly assignable.
22 pub(crate) fn are_object_like(&self, type_id: TypeId) -> bool {
23 use crate::types::TypeData;
24
25 match self.db.lookup(type_id) {
26 Some(
27 TypeData::Object(_)
28 | TypeData::ObjectWithIndex(_)
29 | TypeData::Function(_)
30 | TypeData::Callable(_),
31 ) => true,
32
33 // Interface and class types (which are object-like)
34 Some(TypeData::Application(_)) => {
35 // Check if the application type has construct signatures or object structure
36 use crate::type_queries_extended::InstanceTypeKind;
37 use crate::type_queries_extended::classify_for_instance_type;
38
39 matches!(
40 classify_for_instance_type(self.db, type_id),
41 InstanceTypeKind::Callable(_) | InstanceTypeKind::Function(_)
42 )
43 }
44
45 // Type parameters - check their constraint
46 Some(TypeData::TypeParameter(info)) => {
47 // For instanceof, generics with object constraints are treated as object-like
48 // This allows intersection narrowing for cases like: T & MyClass
49 info.constraint.is_none_or(|c| self.are_object_like(c))
50 }
51
52 // Intersection of object types
53 Some(TypeData::Intersection(members)) => {
54 let members = self.db.type_list(members);
55 members.iter().any(|&member| self.are_object_like(member))
56 }
57
58 _ => false,
59 }
60 }
61
62 /// Narrow a type based on an `in` operator check.
63 ///
64 /// Example: `"a" in x` narrows `A | B` to include only types that have property `a`
65 pub fn narrow_by_property_presence(
66 &self,
67 source_type: TypeId,
68 property_name: Atom,
69 present: bool,
70 ) -> TypeId {
71 let _span = span!(
72 Level::TRACE,
73 "narrow_by_property_presence",
74 source_type = source_type.0,
75 ?property_name,
76 present
77 )
78 .entered();
79
80 // Handle special cases
81 if source_type == TypeId::ANY {
82 trace!("Source type is ANY, returning unchanged");
83 return TypeId::ANY;
84 }
85
86 if source_type == TypeId::NEVER {
87 trace!("Source type is NEVER, returning unchanged");
88 return TypeId::NEVER;
89 }
90
91 if source_type == TypeId::UNKNOWN {
92 if !present {
93 // False branch: property is not present. Since unknown could be anything,
94 // it remains unknown in the false branch.
95 trace!("UNKNOWN in false branch for in operator, returning UNKNOWN");
96 return TypeId::UNKNOWN;
97 }
98
99 // For unknown, narrow to object & { [prop]: unknown }
100 // This matches TypeScript's behavior where `in` check on unknown
101 // narrows to object type with the property
102 let prop_type = TypeId::UNKNOWN;
103 let required_prop = PropertyInfo {
104 name: property_name,
105 type_id: prop_type,
106 write_type: prop_type,
107 optional: false, // Property becomes required after `in` check
108 readonly: false,
109 is_method: false,
110 visibility: Visibility::Public,
111 parent_id: None,
112 };
113 let filter_obj = self.db.object(vec![required_prop]);
114 let narrowed = self.db.intersection2(TypeId::OBJECT, filter_obj);
115 trace!("Narrowing unknown to object & property = {}", narrowed.0);
116 return narrowed;
117 }
118
119 // Handle type parameters: narrow the constraint and intersect if changed
120 if let Some(type_param_info) = type_param_info(self.db, source_type) {
121 if let Some(constraint) = type_param_info.constraint
122 && constraint != source_type
123 {
124 let narrowed_constraint =
125 self.narrow_by_property_presence(constraint, property_name, present);
126 if narrowed_constraint != constraint {
127 trace!(
128 "Type parameter constraint narrowed from {} to {}, creating intersection",
129 constraint.0, narrowed_constraint.0
130 );
131 return self.db.intersection2(source_type, narrowed_constraint);
132 }
133 }
134 // Type parameter with no constraint or unchanged constraint
135 trace!("Type parameter unchanged, returning source");
136 return source_type;
137 }
138
139 // If source is a union, filter members based on property presence
140 if let Some(members_id) = union_list_id(self.db, source_type) {
141 let members = self.db.type_list(members_id);
142 trace!(
143 "Checking property {} in union with {} members",
144 self.db.resolve_atom_ref(property_name),
145 members.len()
146 );
147
148 let matching: Vec<TypeId> = members
149 .iter()
150 .map(|&member| {
151 // CRITICAL: Resolve Lazy types for each member
152 let resolved_member = self.resolve_type(member);
153
154 let has_property = self.type_has_property(resolved_member, property_name);
155 if present {
156 // Positive: "prop" in member
157 if has_property {
158 // Property exists: Keep the member as-is
159 // CRITICAL: For union narrowing, we don't modify the member type
160 // We just filter to keep only members that have the property
161 member
162 } else {
163 // Property not found: Exclude member (return NEVER)
164 // Per TypeScript: "prop in x" being true means x MUST have the property
165 // If x doesn't have it (and no index signature), narrow to never
166 TypeId::NEVER
167 }
168 } else {
169 // Negative: !("prop" in member)
170 // Exclude member ONLY if property is required
171 if self.is_property_required(resolved_member, property_name) {
172 return TypeId::NEVER;
173 }
174 // Keep member (no required property found, or property is optional)
175 member
176 }
177 })
178 .collect();
179
180 // CRITICAL FIX: Filter out NEVER types before creating the union
181 // When a union member doesn't have the required property, it becomes NEVER
182 // and should be EXCLUDED from the result, not included in the union
183 let matching_non_never: Vec<TypeId> = matching
184 .into_iter()
185 .filter(|&t| t != TypeId::NEVER)
186 .collect();
187
188 if matching_non_never.is_empty() {
189 trace!("All members were NEVER, returning NEVER");
190 return TypeId::NEVER;
191 } else if matching_non_never.len() == 1 {
192 trace!(
193 "Found single member after filtering, returning {}",
194 matching_non_never[0].0
195 );
196 return matching_non_never[0];
197 }
198 trace!("Created union with {} members", matching_non_never.len());
199 return self.db.union(matching_non_never);
200 }
201
202 // For non-union types, check if the property exists
203 // CRITICAL: Resolve Lazy types before checking
204 let resolved_type = self.resolve_type(source_type);
205 let has_property = self.type_has_property(resolved_type, property_name);
206
207 if present {
208 // Positive: "prop" in x
209 if has_property {
210 // Property exists: Promote to required
211 let prop_type = self.get_property_type(resolved_type, property_name);
212 let required_prop = PropertyInfo {
213 name: property_name,
214 type_id: prop_type.unwrap_or(TypeId::UNKNOWN),
215 write_type: prop_type.unwrap_or(TypeId::UNKNOWN),
216 optional: false,
217 readonly: false,
218 is_method: false,
219 visibility: Visibility::Public,
220 parent_id: None,
221 };
222 let filter_obj = self.db.object(vec![required_prop]);
223 self.db.intersection2(source_type, filter_obj)
224 } else {
225 // Property not found: Narrow to never
226 // Per TypeScript: "prop in x" being true means x MUST have the property
227 // If x doesn't have it (and no index signature), narrow to never
228 TypeId::NEVER
229 }
230 } else {
231 // Negative: !("prop" in x)
232 // Exclude ONLY if property is required (not optional)
233 if self.is_property_required(resolved_type, property_name) {
234 return TypeId::NEVER;
235 }
236 // Keep source_type (no required property found, or property is optional)
237 source_type
238 }
239 }
240
241 /// Check if a type has a specific property.
242 ///
243 /// Returns true if the type has the property (required or optional),
244 /// or has an index signature that would match the property.
245 pub(crate) fn type_has_property(&self, type_id: TypeId, property_name: Atom) -> bool {
246 self.get_property_type(type_id, property_name).is_some()
247 }
248
249 /// Check if a property exists and is required on a type.
250 ///
251 /// Returns true if the property is required (not optional).
252 /// This is used for negative narrowing: `!("prop" in x)` should
253 /// exclude types where `prop` is required.
254 pub(crate) fn is_property_required(&self, type_id: TypeId, property_name: Atom) -> bool {
255 let resolved_type = self.resolve_type(type_id);
256
257 // Helper to check a specific shape
258 let check_shape = |shape_id: ObjectShapeId| -> bool {
259 let shape = self.db.object_shape(shape_id);
260 if let Some(prop) = PropertyInfo::find_in_slice(&shape.properties, property_name) {
261 return !prop.optional;
262 }
263 false
264 };
265
266 // Check standard object shape
267 if let Some(shape_id) = object_shape_id(self.db, resolved_type)
268 && check_shape(shape_id)
269 {
270 return true;
271 }
272
273 // Check object with index shape (CRITICAL for interfaces/classes)
274 if let Some(shape_id) = object_with_index_shape_id(self.db, resolved_type)
275 && check_shape(shape_id)
276 {
277 return true;
278 }
279
280 // Check intersection members
281 // If ANY member requires it, the intersection requires it
282 if let Some(members_id) = intersection_list_id(self.db, resolved_type) {
283 let members = self.db.type_list(members_id);
284 return members
285 .iter()
286 .any(|&m| self.is_property_required(m, property_name));
287 }
288
289 false
290 }
291
292 /// Get the type of a property if it exists.
293 ///
294 /// Returns Some(type) if the property exists, None otherwise.
295 pub(crate) fn get_property_type(&self, type_id: TypeId, property_name: Atom) -> Option<TypeId> {
296 // CRITICAL: Resolve Lazy types before checking for properties
297 // This ensures type aliases are resolved to their actual types
298 let resolved_type = self.resolve_type(type_id);
299
300 // Check intersection types - property exists if ANY member has it
301 if let Some(members_id) = intersection_list_id(self.db, resolved_type) {
302 let members = self.db.type_list(members_id);
303 // Return the type from the first member that has the property
304 for &member in members.iter() {
305 // Resolve each member in the intersection
306 let resolved_member = self.resolve_type(member);
307 if let Some(prop_type) = self.get_property_type(resolved_member, property_name) {
308 return Some(prop_type);
309 }
310 }
311 return None;
312 }
313
314 // Check object shape
315 if let Some(shape_id) = object_shape_id(self.db, resolved_type) {
316 let shape = self.db.object_shape(shape_id);
317
318 // Check if the property exists in the object's properties
319 if let Some(prop) = PropertyInfo::find_in_slice(&shape.properties, property_name) {
320 return Some(prop.type_id);
321 }
322
323 // Check index signatures
324 // If the object has a string index signature, it has any string property
325 if let Some(ref string_idx) = shape.string_index {
326 // String index signature matches any string property
327 return Some(string_idx.value_type);
328 }
329
330 // If the object has a number index signature and the property name is numeric
331 if let Some(ref number_idx) = shape.number_index {
332 let prop_str = self.db.resolve_atom_ref(property_name);
333 if prop_str.chars().all(|c| c.is_ascii_digit()) {
334 return Some(number_idx.value_type);
335 }
336 }
337
338 return None;
339 }
340
341 // Check object with index signature
342 if let Some(shape_id) = object_with_index_shape_id(self.db, resolved_type) {
343 let shape = self.db.object_shape(shape_id);
344
345 // Check properties first
346 if let Some(prop) = PropertyInfo::find_in_slice(&shape.properties, property_name) {
347 return Some(prop.type_id);
348 }
349
350 // Check index signatures
351 if let Some(ref string_idx) = shape.string_index {
352 return Some(string_idx.value_type);
353 }
354
355 if let Some(ref number_idx) = shape.number_index {
356 let prop_str = self.db.resolve_atom_ref(property_name);
357 if prop_str.chars().all(|c| c.is_ascii_digit()) {
358 return Some(number_idx.value_type);
359 }
360 }
361
362 return None;
363 }
364
365 // For other types (functions, classes, arrays, etc.), assume they don't have arbitrary properties
366 // unless they have been handled above (object shapes, etc.)
367 None
368 }
369}