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