Skip to main content

submilli_engine/typechecker/infer/
narrowing.rs

1//! Narrowing engine — pure data structures.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use crate::{ExprId, Ident, MangledName, Type, types::LiteralF64};
6
7#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub struct ReferencePath {
9    pub root: BindingId,
10    pub chain: Vec<PathElem>,
11}
12
13impl ReferencePath {
14    /// A local itself, with no field or element step. Only such a path can't
15    /// change behind a guard's back, through an alias or in a call.
16    pub fn is_bare_local(&self) -> bool {
17        self.chain.is_empty() && matches!(self.root, BindingId::Local { .. })
18    }
19
20    pub fn root(root: BindingId) -> Self {
21        Self {
22            root,
23            chain: Vec::new(),
24        }
25    }
26
27    /// Whether a write to `self` may change what `other` reads. A key step
28    /// of `other` (`o[key]`) may name any field or element, so any written
29    /// step matches it. A written key matches only itself, as in
30    /// TypeScript: `values[index] = 4` leaves `values[0]` narrowed.
31    pub fn is_prefix_of(&self, other: &ReferencePath) -> bool {
32        self.root == other.root
33            && self.chain.len() <= other.chain.len()
34            && self
35                .chain
36                .iter()
37                .zip(&other.chain)
38                .all(|(written, read)| read.is_written_by(written))
39    }
40
41    pub fn render(&self) -> String {
42        let mut out = self.root.render();
43        for elem in &self.chain {
44            match elem {
45                PathElem::Field(name) => {
46                    out.push('.');
47                    out.push_str(name);
48                }
49                PathElem::Key(binding, _) => {
50                    out.push('[');
51                    out.push_str(&binding.render());
52                    out.push(']');
53                }
54                PathElem::Index(lit) => {
55                    use std::fmt::Write;
56                    match lit {
57                        LiteralValue::Number(n) => {
58                            let _ = write!(out, "[{}]", n.0);
59                        }
60                        LiteralValue::String(s) => {
61                            let _ = write!(out, "[{s:?}]");
62                        }
63                        LiteralValue::Boolean(b) => {
64                            let _ = write!(out, "[{b}]");
65                        }
66                        LiteralValue::BigInt(digits) => {
67                            let _ = write!(out, "[{digits}n]");
68                        }
69                        LiteralValue::Null => {
70                            let _ = write!(out, "[null]");
71                        }
72                        LiteralValue::Undefined => {
73                            let _ = write!(out, "[undefined]");
74                        }
75                    }
76                }
77            }
78        }
79        out
80    }
81}
82
83/// `decl_scope` on `Local` is non-optional for correctness: name-only keying
84/// lets inner scopes incorrectly inherit outer narrowings.
85///
86/// `This` needs no payload: narrow scopes are per function body and a closure
87/// boundary resets them, so within one body `this` names exactly one receiver.
88#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
89pub enum BindingId {
90    Local { name: String, decl_scope: ScopeId },
91    Global(MangledName),
92    This,
93}
94
95impl BindingId {
96    /// The name the source reads the binding by.
97    pub fn render(&self) -> String {
98        match self {
99            BindingId::Local { name, .. } => name.clone(),
100            BindingId::Global(mangled) => mangled
101                .as_str()
102                .rsplit('#')
103                .next()
104                .unwrap_or(mangled.as_str())
105                .to_string(),
106            BindingId::This => "this".to_string(),
107        }
108    }
109}
110
111#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
112pub struct ScopeId(pub u32);
113
114/// `Index` is restricted to constant literal indices so paths stay hash-comparable;
115/// `Key` indexes by a binding that holds one value for its whole life (a
116/// `const`, or a parameter or `let` never assigned), as TypeScript narrows
117/// `obj[key]`. Other index expressions are not narrowable.
118#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
119pub enum PathElem {
120    Field(String),
121    Index(LiteralValue),
122    Key(BindingId, KeyKind),
123}
124
125impl PathElem {
126    fn is_written_by(&self, written: &PathElem) -> bool {
127        matches!(self, PathElem::Key(..)) || self == written
128    }
129}
130
131/// What a [`PathElem::Key`] reads: a property, by a string key, or an element.
132#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
133pub enum KeyKind {
134    Property,
135    Element,
136}
137
138#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
139pub enum LiteralValue {
140    Number(LiteralF64),
141    String(String),
142    Boolean(bool),
143    /// A bigint's canonical decimal digits, as [`Type::BigIntLiteral`] holds them.
144    BigInt(String),
145    /// Discriminant values only: a member may name its variant with `null` or
146    /// `undefined`, and an optional discriminant is `undefined` when absent.
147    Null,
148    Undefined,
149}
150
151impl LiteralValue {
152    /// The value an enum member literal type `E.A` holds.
153    pub fn of_enum_member(ty: &Type) -> Option<Self> {
154        Some(match ty.enum_member_value()? {
155            crate::types::EnumValue::Number(value) => LiteralValue::Number(value),
156            crate::types::EnumValue::String(value) => LiteralValue::String(value),
157        })
158    }
159}
160
161/// Bitmask over narrowing predicates, modeled on TypeScript's `TypeFacts`.
162#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
163pub struct TypeFacts(pub u16);
164
165impl TypeFacts {
166    pub const EMPTY: Self = Self(0);
167    pub const TRUTHY: Self = Self(1 << 0);
168    pub const FALSY: Self = Self(1 << 1);
169    pub const EQ_NULL: Self = Self(1 << 2);
170    pub const NE_NULL: Self = Self(1 << 3);
171    pub const IS_NUMBER: Self = Self(1 << 4);
172    pub const IS_STRING: Self = Self(1 << 5);
173    pub const IS_BOOLEAN: Self = Self(1 << 6);
174    pub const IS_OBJECT: Self = Self(1 << 7);
175    pub const IS_FUNCTION: Self = Self(1 << 8);
176    pub const IS_ARRAY: Self = Self(1 << 9);
177    pub const EQ_UNDEFINED: Self = Self(1 << 10);
178    pub const NE_UNDEFINED: Self = Self(1 << 11);
179    pub const IS_BIGINT: Self = Self(1 << 12);
180    pub const IS_UNDEFINED: Self = Self(1 << 13);
181
182    pub fn contains(self, other: Self) -> bool {
183        (self.0 & other.0) == other.0
184    }
185
186    pub fn is_empty(self) -> bool {
187        self.0 == 0
188    }
189}
190
191impl std::ops::BitOr for TypeFacts {
192    type Output = Self;
193    fn bitor(self, rhs: Self) -> Self {
194        Self(self.0 | rhs.0)
195    }
196}
197
198impl std::ops::BitOrAssign for TypeFacts {
199    fn bitor_assign(&mut self, rhs: Self) {
200        self.0 |= rhs.0;
201    }
202}
203
204impl std::ops::BitAnd for TypeFacts {
205    type Output = Self;
206    fn bitand(self, rhs: Self) -> Self {
207        Self(self.0 & rhs.0)
208    }
209}
210
211impl std::ops::Not for TypeFacts {
212    type Output = Self;
213    fn not(self) -> Self {
214        const ALL: u16 = (1 << 14) - 1;
215        Self((!self.0) & ALL)
216    }
217}
218
219/// Pushed on loop/switch entry, popped on exit; collects break and continue narrow-state snapshots.
220#[derive(Debug)]
221pub struct PendingJoinFrame {
222    pub kind: PendingJoinKind,
223    /// `narrow_scopes.len()` at the moment the construct was
224    /// entered, *before* the construct's body frame is pushed.
225    /// Snapshots taken at `break`/`continue` time use this as the
226    /// inclusive base — every frame at index >= `narrow_depth`
227    /// was pushed inside the construct and is folded into the
228    /// snapshot's narrow env (innermost wins on lookup).
229    pub narrow_depth: usize,
230    pub breaks: Vec<(NarrowEnv, BTreeSet<ReferencePath>)>,
231    pub continues: Vec<(NarrowEnv, BTreeSet<ReferencePath>)>,
232}
233
234#[derive(Copy, Clone, Debug, PartialEq, Eq)]
235pub enum PendingJoinKind {
236    /// continue targets the innermost frame of this kind.
237    Loop,
238    /// Transparent to `continue` — search past this frame when finding the continue target.
239    Switch,
240}
241
242#[derive(Clone, Debug, PartialEq, Eq)]
243pub struct NarrowedView {
244    pub narrowed_ty: Type,
245    pub facts: TypeFacts,
246    /// Literal values excluded by negative predicates (e.g., `s.kind !== "circle"`
247    /// adds `"circle"` here).
248    pub excluded_literals: BTreeSet<LiteralValue>,
249    /// Shadow Wasm local binding name (`#narrow_<N>`) codegen uses for in-region
250    /// references to this narrowed path.
251    pub binding: Ident,
252    /// Un-narrowed source expression codegen evaluates at `NarrowRegion` entry.
253    /// Minted fresh — not shared with other AST positions (would double-trigger
254    /// walker visits).
255    pub source: ExprId,
256}
257
258/// The cast is **unchecked**: codegen trusts the typechecker's narrowing decision. If the
259/// predicate didn't actually prove what the narrowing claims, the Wasm-level cast traps.
260#[derive(Clone, Debug, PartialEq, Eq)]
261pub struct CastInfo {
262    pub from_ty: Type,
263    pub to_ty: Type,
264    pub cast_kind: CastKind,
265}
266
267/// Dispatch hint for `emit_narrowing_cast`. Variants carry **no Wasm-level
268/// indices** — those live in codegen's own registries.
269#[derive(Copy, Clone, Debug, PartialEq, Eq)]
270pub enum CastKind {
271    /// `T | null` → `T`. Codegen emits `ref.as_non_null`.
272    NonNull,
273    /// Boxed primitive → unboxed primitive value. Codegen emits an
274    /// (optional) `ref.as_non_null` (when `from_ty` is nullable), a
275    /// `ref.cast` to the wrapper subtype (`$BoxedNumber`/`$BoxedBoolean`),
276    /// and `struct.get $value`.
277    Unbox,
278    /// Ref-typed value → more-specific ref subtype. Codegen emits `ref.cast`
279    /// to the registered subtype when `to_ty`'s Wasm repr is tighter than
280    /// `from_ty`'s; emits no instruction when reps match.
281    RefSubtype,
282}
283
284/// Keep in sync with `emit_narrowing_cast` in `codegen/function_emitter/cast.rs`.
285pub fn cast_info_for(from_ty: Type, narrowed_ty: Type) -> CastInfo {
286    let cast_kind = match &narrowed_ty {
287        Type::Number | Type::NumberLiteral(_) | Type::Boolean | Type::BooleanLiteral(_) => {
288            CastKind::Unbox
289        }
290        Type::Object { .. } | Type::InterfaceRef { .. } | Type::ClassRef { .. } => {
291            CastKind::RefSubtype
292        }
293        // unknown → ref-typed concrete shape uses the same downcast.
294        Type::String
295        | Type::StringLiteral(_)
296        | Type::Array(_)
297        | Type::Tuple(_)
298        | Type::Uint8Array
299            if matches!(from_ty.peel(), Type::Unknown) =>
300        {
301            CastKind::RefSubtype
302        }
303        _ => CastKind::NonNull,
304    };
305    CastInfo {
306        from_ty,
307        to_ty: narrowed_ty,
308        cast_kind,
309    }
310}
311
312/// One field-path narrowing that survived the `if`-join and needs a
313/// fresh shadow Wasm local materialized at the join site.
314#[derive(Clone, Debug)]
315pub struct PendingPostIfMaterialization {
316    pub path: ReferencePath,
317    pub source: ExprId,
318    pub binding: Ident,
319    pub cast_info: CastInfo,
320    pub span: crate::Span,
321}
322
323/// Diagnostic-only record of why a narrowing was invalidated. Surfaced as a
324/// `help:` line so the LLM doesn't have to guess why a narrowing went away.
325#[derive(Clone, Debug)]
326pub enum InvalidationReason {
327    /// A proven predicate had no source that codegen could rebuild in its region.
328    ShapeUnrebuildable { narrowed_ty: Type },
329    /// A write reached the path (or a prefix of it) and falsified the guard:
330    /// a field or index write, a loop back edge, or a merge of an inner block's
331    /// writes. Narrowings on the path and any extension of it are dropped.
332    Write { span: crate::Span },
333    /// An identifier was reassigned. Distinguished from [`Self::Write`] only so
334    /// the diagnostic can name the statement the reader is looking at.
335    Reassignment { span: crate::Span },
336    /// Path root is in captured_mutators; the engine refused to install a NarrowedView.
337    /// closure_span: one offending closure declaration.
338    CapturedMutator { closure_span: Option<crate::Span> },
339}
340
341impl InvalidationReason {
342    pub fn invalidates(&self) -> bool {
343        !matches!(self, Self::ShapeUnrebuildable { .. })
344    }
345
346    pub fn span(&self) -> Option<crate::Span> {
347        match self {
348            Self::ShapeUnrebuildable { .. } => None,
349            Self::Write { span } => Some(*span),
350            Self::Reassignment { span } => Some(*span),
351            Self::CapturedMutator { closure_span } => *closure_span,
352        }
353    }
354}
355
356/// A union's discriminant: the property every member types with a distinct
357/// literal, and which literal names which member.
358pub type Discriminant = (String, DiscriminantTable);
359
360/// The literals that name a union's members. Each literal names one member,
361/// except `undefined`: every member whose discriminant is optional reads it
362/// when the field is absent, so it may name several.
363#[derive(Clone, Debug, Default, PartialEq)]
364pub struct DiscriminantTable {
365    entries: Vec<(LiteralValue, VariantIdx)>,
366}
367
368/// How many members `undefined` may name in a [`DiscriminantTable`].
369#[derive(Clone, Copy, PartialEq, Eq)]
370enum UndefinedNames {
371    /// An object field: every member with an optional discriminant reads it.
372    Several,
373    /// A tuple position, which is always present: like any literal, one.
374    One,
375}
376
377impl DiscriminantTable {
378    pub fn iter(&self) -> impl Iterator<Item = (&LiteralValue, &VariantIdx)> {
379        self.entries.iter().map(|(value, variant)| (value, variant))
380    }
381
382    /// The member `value` names, when it names exactly one.
383    pub fn get(&self, value: &LiteralValue) -> Option<VariantIdx> {
384        let mut variants = self.variants_for(value);
385        let variant = variants.next()?;
386        variants.next().is_none().then_some(variant)
387    }
388
389    /// Every member `value` names.
390    pub fn variants_for<'a>(
391        &'a self,
392        value: &'a LiteralValue,
393    ) -> impl Iterator<Item = VariantIdx> + 'a {
394        self.entries
395            .iter()
396            .filter(move |(entry, _)| entry == value)
397            .map(|(_, variant)| *variant)
398    }
399
400    /// Whether some literal other than `value` also names `variant`, so a
401    /// test against `value` alone can't rule it out.
402    pub fn has_other_values(&self, variant: VariantIdx, value: &LiteralValue) -> bool {
403        self.entries
404            .iter()
405            .any(|(entry, named)| *named == variant && entry != value)
406    }
407
408    /// Whether a `switch` covering `covered` handles `variant`: every value
409    /// that names it is a case.
410    pub fn is_covered(&self, variant: VariantIdx, covered: &BTreeSet<LiteralValue>) -> bool {
411        self.entries
412            .iter()
413            .filter(|(_, named)| *named == variant)
414            .all(|(value, _)| covered.contains(value))
415    }
416
417    /// The `members` a `switch` covering `covered` still leaves possible.
418    pub fn uncovered_members(
419        &self,
420        members: &[Type],
421        covered: &BTreeSet<LiteralValue>,
422    ) -> Vec<Type> {
423        members
424            .iter()
425            .enumerate()
426            .filter(|(index, _)| {
427                u32::try_from(*index)
428                    .map_or(true, |index| !self.is_covered(VariantIdx(index), covered))
429            })
430            .map(|(_, member)| member.clone())
431            .collect()
432    }
433
434    /// The values `covered` leaves out, each once, in table order.
435    pub fn uncovered(&self, covered: &BTreeSet<LiteralValue>) -> Vec<LiteralValue> {
436        let mut missing: Vec<LiteralValue> = Vec::new();
437        for (value, _) in &self.entries {
438            if !covered.contains(value) && !missing.contains(value) {
439                missing.push(value.clone());
440            }
441        }
442        missing
443    }
444
445    /// `members` split by a test against `literal`: the members it names,
446    /// and the ones still possible when the test fails. A named member stays
447    /// on both sides when another value also names it, as an optional
448    /// `kind?: "a"` does with `undefined`. `None` when `literal` names none.
449    pub fn partition(
450        &self,
451        members: &[Type],
452        literal: &LiteralValue,
453    ) -> Option<(Vec<Type>, Vec<Type>)> {
454        let named: Vec<VariantIdx> = self.variants_for(literal).collect();
455        if named.is_empty() {
456            return None;
457        }
458        let mut matched = Vec::with_capacity(named.len());
459        let mut remaining = Vec::new();
460        for (index, member) in members.iter().enumerate() {
461            let variant = VariantIdx(u32::try_from(index).ok()?);
462            if !named.contains(&variant) {
463                remaining.push(member.clone());
464                continue;
465            }
466            matched.push(member.clone());
467            if self.has_other_values(variant, literal) {
468                remaining.push(member.clone());
469            }
470        }
471        Some((matched, remaining))
472    }
473
474    /// Add `value` for `variant`, unless it already names a member: then the
475    /// key or position is no discriminant, and this returns `None`.
476    fn insert(
477        &mut self,
478        value: LiteralValue,
479        variant: VariantIdx,
480        undefined: UndefinedNames,
481    ) -> Option<()> {
482        let shareable = undefined == UndefinedNames::Several && value == LiteralValue::Undefined;
483        if !shareable && self.entries.iter().any(|(entry, _)| *entry == value) {
484            return None;
485        }
486        self.entries.push((value, variant));
487        Some(())
488    }
489}
490
491/// Variant index in a `Type::Union`'s canonical member list.
492#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
493pub struct VariantIdx(pub u32);
494
495/// Result of analyzing a `Type::Union` for a discriminant property.
496///
497/// `Some((key_property, constituent_map))` when there exists a property that:
498///
499/// - appears on every member of the union,
500/// - has a unit (literal) type on every member,
501/// - uniquely identifies each member.
502///
503/// `None` otherwise.
504///
505/// The key is returned as a bare `String` (not `Ident`) — the discriminant
506/// property name has no meaningful source span at the union level; callers
507/// compare it against `PathElem::Field(String)` from a `ReferencePath` anyway.
508pub fn union_discriminant(members: &[Type]) -> Option<Discriminant> {
509    // Inline object shapes only. A nominal member (interface, class) needs the
510    // type registry to expand, which callers that have an `Inferer` supply
511    // through [`discriminant_from_shapes`].
512    //
513    // Peel aliases so `type Shape = Circle | Rectangle` finds its discriminant.
514    let shapes = inline_member_shapes(members)?;
515    discriminant_from_shapes(&shapes)
516}
517
518/// Each member's fields, when every member is an inline object type.
519/// Nominal members need the type registry; see `union_member_shapes`.
520fn inline_member_shapes(
521    members: &[Type],
522) -> Option<Vec<BTreeMap<String, crate::types::ObjectField>>> {
523    members
524        .iter()
525        .map(|m| match m.peel() {
526            Type::Object { fields, .. } => Some(fields.clone()),
527            _ => None,
528        })
529        .collect()
530}
531
532/// [`union_discriminant`] for the field a test names, rather than the one
533/// the union would pick on its own. Inline object members only; the
534/// typechecker's `Inferer::union_discriminant_for_key` also expands nominal ones.
535pub fn inline_union_discriminant_for_key(members: &[Type], key: &str) -> Option<DiscriminantTable> {
536    let shapes = inline_member_shapes(members)?;
537    discriminant_table_for_key(&shapes, key)
538}
539
540/// [`union_discriminant`] over already-expanded member shapes — one map per
541/// union member, in member order. A key every member requires wins over one
542/// a member may omit, so an optional `code?: 1` beside `kind` doesn't hide
543/// the tag; ties go to the first key in order.
544pub fn discriminant_from_shapes(
545    shapes: &[BTreeMap<String, crate::types::ObjectField>],
546) -> Option<Discriminant> {
547    let first = shapes.first()?;
548    let may_be_omitted = |key: &String| {
549        shapes
550            .iter()
551            .any(|shape| shape.get(key).is_some_and(|f| f.optional))
552    };
553    let (required, omittable): (Vec<&String>, Vec<&String>) =
554        first.keys().partition(|key| !may_be_omitted(key));
555    required
556        .into_iter()
557        .chain(omittable)
558        .find_map(|key| Some((key.clone(), discriminant_table_for_key(shapes, key)?)))
559}
560
561/// The table `key` makes a discriminant of, when every member types it with a
562/// literal and no explicit literal names two members.
563pub fn discriminant_table_for_key(
564    shapes: &[BTreeMap<String, crate::types::ObjectField>],
565    key: &str,
566) -> Option<DiscriminantTable> {
567    if shapes.len() < 2 {
568        return None;
569    }
570    let mut table = DiscriminantTable::default();
571    for (idx, shape) in shapes.iter().enumerate() {
572        let field = shape.get(key)?;
573        let lit = unit_value(&field.ty)?;
574        // An optional `kind?: "err"` reads `undefined` when absent, so it
575        // names its variant by both values, as TypeScript narrows it.
576        let absent =
577            (field.optional && lit != LiteralValue::Undefined).then_some(LiteralValue::Undefined);
578        let variant = VariantIdx(u32::try_from(idx).ok()?);
579        for value in std::iter::once(lit).chain(absent) {
580            table.insert(value, variant, UndefinedNames::Several)?;
581        }
582    }
583    Some(table)
584}
585
586/// Tuple-union analog of [`union_discriminant`]: find a position whose element
587/// type is a literal and pairwise distinct across variants.
588/// Positions are tried only up to the smallest variant's arity.
589pub fn tuple_union_discriminant(members: &[Type]) -> Option<(usize, DiscriminantTable)> {
590    if members.len() < 2 {
591        return None;
592    }
593    let tuples: Vec<&crate::types::TupleType> = members
594        .iter()
595        .map(|m| match m.peel() {
596            Type::Tuple(elements) => Some(elements),
597            _ => None,
598        })
599        .collect::<Option<_>>()?;
600    let min_arity = tuples.iter().map(|t| t.required_len()).min().unwrap_or(0);
601    (0..min_arity).find_map(|position| {
602        let mut table = DiscriminantTable::default();
603        for (idx, tuple) in tuples.iter().enumerate() {
604            let lit = tuple.get(position).and_then(unit_value)?;
605            // Positions are required, so each one names one member, `undefined`
606            // included.
607            table.insert(
608                lit,
609                VariantIdx(u32::try_from(idx).ok()?),
610                UndefinedNames::One,
611            )?;
612        }
613        Some((position, table))
614    })
615}
616
617/// The literal a `case` label compares against: `None` for an expression
618/// label whose type is no single literal.
619pub fn switch_value_literal(value: &crate::TypedSwitchValue) -> Option<LiteralValue> {
620    Some(match value {
621        crate::TypedSwitchValue::String { value, .. } => LiteralValue::String(value.clone()),
622        crate::TypedSwitchValue::Number { value, .. } => {
623            LiteralValue::Number(crate::types::LiteralF64(*value))
624        }
625        crate::TypedSwitchValue::Boolean { value, .. } => LiteralValue::Boolean(*value),
626        crate::TypedSwitchValue::Null { .. } => LiteralValue::Null,
627        crate::TypedSwitchValue::Undefined { .. } => LiteralValue::Undefined,
628        crate::TypedSwitchValue::Expr { literal, .. } => return literal.clone(),
629        crate::TypedSwitchValue::Enum { value, .. } => match value {
630            crate::EnumVariantPayload::Number(n) => {
631                LiteralValue::Number(crate::types::LiteralF64(*n))
632            }
633            crate::EnumVariantPayload::String(s) => LiteralValue::String(s.clone()),
634        },
635    })
636}
637
638/// The literal type a compared literal value has.
639pub fn literal_type(literal: &LiteralValue) -> Type {
640    match literal {
641        LiteralValue::String(s) => Type::StringLiteral(s.clone()),
642        LiteralValue::Number(n) => Type::NumberLiteral(*n),
643        LiteralValue::Boolean(b) => Type::BooleanLiteral(*b),
644        LiteralValue::BigInt(digits) => Type::BigIntLiteral(digits.clone()),
645        LiteralValue::Null => Type::Null,
646        LiteralValue::Undefined => Type::Undefined,
647    }
648}
649
650/// Whether `ty` is, or has a member that is, a literal, `null` or
651/// `undefined`: what makes a property a discriminant in TypeScript. An enum
652/// is the union of its members' literals.
653pub fn has_unit_member(ty: &Type) -> bool {
654    match ty.peel() {
655        Type::Union(members) => members.iter().any(has_unit_member),
656        Type::Null | Type::Undefined | Type::NumberEnum { .. } | Type::StringEnum { .. } => true,
657        other => unit_literal_value(other).is_some(),
658    }
659}
660
661/// Whether `case` labels for the `covered` literals, `null` and `undefined`
662/// included, match every value of `ty`.
663pub fn is_covered_by_literals(ty: &Type, covered: &BTreeSet<LiteralValue>) -> bool {
664    match ty.peel() {
665        Type::Union(members) => members
666            .iter()
667            .all(|member| is_covered_by_literals(member, covered)),
668        // `boolean` is `true | false`.
669        Type::Boolean => [true, false]
670            .into_iter()
671            .all(|value| covered.contains(&LiteralValue::Boolean(value))),
672        other => unit_value(other).is_some_and(|value| covered.contains(&value)),
673    }
674}
675
676/// Whether `ty` is a type parameter, or a union with one.
677pub fn has_type_parameter_member(ty: &Type) -> bool {
678    match ty.peel() {
679        Type::Union(members) => members.iter().any(has_type_parameter_member),
680        other => matches!(other, Type::TypeVar(_) | Type::GenericParam { .. }),
681    }
682}
683
684pub(super) fn unit_literal_value(ty: &Type) -> Option<LiteralValue> {
685    match ty.peel() {
686        Type::StringLiteral(s) => Some(LiteralValue::String(s.clone())),
687        Type::NumberLiteral(n) => Some(LiteralValue::Number(*n)),
688        Type::BooleanLiteral(b) => Some(LiteralValue::Boolean(*b)),
689        Type::BigIntLiteral(digits) => Some(LiteralValue::BigInt(digits.clone())),
690        other => LiteralValue::of_enum_member(other),
691    }
692}
693
694/// [`unit_literal_value`], with `null` and `undefined` as values too: what a
695/// discriminant or a `case` label may name.
696pub(super) fn unit_value(ty: &Type) -> Option<LiteralValue> {
697    match ty.peel() {
698        Type::Null => Some(LiteralValue::Null),
699        Type::Undefined | Type::Void => Some(LiteralValue::Undefined),
700        other => unit_literal_value(other),
701    }
702}
703
704/// Strip `Type::Null` from a union. Returns `Type::Error` for `Type::Null`
705/// itself (empty union has no representation).
706pub fn strip_null(ty: &Type) -> Type {
707    if let Type::Refined {
708        original,
709        ty: shape,
710    } = ty.without_aliases()
711    {
712        return preserve_refinement(original, strip_null(shape));
713    }
714    match ty.peel() {
715        Type::Null => Type::Error,
716        Type::Union(members) => {
717            let non_null: Vec<Type> = members
718                .iter()
719                .filter(|m| !matches!(m.peel(), Type::Null))
720                .cloned()
721                .collect();
722            Type::union(non_null)
723        }
724        // `unknown` may or may not be null. Stripping null
725        // leaves `unknown` — we don't statically know the non-null
726        // subset. The NE_NULL fact attached by the caller carries the
727        // load-bearing information; the narrowed type stays dynamic.
728        Type::Unknown => Type::Unknown,
729        // `ty` itself, not its peel: peeling drops a `readonly` wrapper.
730        _ => ty.clone(),
731    }
732}
733
734/// Remove only `undefined`, preserving strict equality's distinction from null.
735/// A `void` member stays: a `void` result may hold any value, so only a
736/// `typeof` test narrows it.
737pub fn strip_undefined(ty: &Type) -> Type {
738    if let Type::Refined {
739        original,
740        ty: shape,
741    } = ty.without_aliases()
742    {
743        return preserve_refinement(original, strip_undefined(shape));
744    }
745    match ty.peel() {
746        Type::Undefined => Type::Error,
747        Type::Union(members) => Type::union(
748            members
749                .iter()
750                .filter(|member| !matches!(member.peel(), Type::Undefined))
751                .cloned()
752                .collect(),
753        ),
754        _ => ty.clone(),
755    }
756}
757
758/// Remove both nullish alternatives for optional access and non-null assertions.
759pub fn strip_nullish(ty: &Type) -> Type {
760    strip_undefined(&strip_null(ty))
761}
762
763/// `in` retains optional members in both branches: declaring a property does
764/// not prove that its value is defined. A present optional property can still
765/// hold `undefined`, so its read type retains optionality.
766pub fn narrow_field_presence(
767    receiver_ty: &Type,
768    field: &str,
769    lookup: &dyn Fn(&Type, &str) -> Option<crate::ObjectField>,
770) -> (Type, Type) {
771    if let Type::Refined {
772        original,
773        ty: shape,
774    } = receiver_ty.without_aliases()
775    {
776        let (present, absent) = narrow_field_presence(shape, field, lookup);
777        return (
778            preserve_refinement(original, present),
779            preserve_refinement(original, absent),
780        );
781    }
782    match receiver_ty.peel() {
783        Type::Unknown => {
784            let fields = BTreeMap::from([(
785                field.to_string(),
786                crate::ObjectField::required(Type::Unknown),
787            )]);
788            (
789                Type::Object {
790                    index: None,
791                    fields,
792                },
793                Type::Unknown,
794            )
795        }
796        Type::Object { fields, .. } => {
797            let mut present = fields.clone();
798            present
799                .entry(field.to_string())
800                .or_insert_with(|| crate::ObjectField::required(Type::Unknown));
801            let absent = match fields.get(field) {
802                Some(f) if !f.optional => Type::Error,
803                _ => receiver_ty.clone(),
804            };
805            (
806                Type::Object {
807                    index: None,
808                    fields: present,
809                },
810                absent,
811            )
812        }
813        Type::Union(members) => {
814            let has = members
815                .iter()
816                .filter(|m| lookup(m, field).is_some())
817                .cloned()
818                .collect();
819            let lacks = members
820                .iter()
821                .filter(|m| lookup(m, field).is_none_or(|f| f.optional))
822                .cloned()
823                .collect();
824            (Type::union(has), Type::union(lacks))
825        }
826        Type::InterfaceRef { .. } | Type::ClassRef { .. } => {
827            let absent = if lookup(receiver_ty, field).is_some_and(|f| !f.optional) {
828                Type::Error
829            } else {
830                receiver_ty.clone()
831            };
832            (receiver_ty.clone(), absent)
833        }
834        _ => (Type::Error, Type::Error),
835    }
836}
837
838/// `BTreeMap`, not `HashMap`, so a walk over an env is reproducible. Under a hash map
839/// `enter_closure_narrow_boundary` would hand the same program different `#narrow_<N>`
840/// numbering and different `ExprId`s per compile, and `install_joined_narrowings`'s
841/// `joined_rebound` and `retain_emittable_views`'s `candidates` would inherit that order.
842/// Only the region nesting reaches the emitted Wasm — [`wrap_order`] pins that, and
843/// locals are unnamed there — so this is defence in depth for everything else.
844#[derive(Clone, Debug, Default)]
845pub struct NarrowEnv {
846    views: BTreeMap<ReferencePath, NarrowedView>,
847    /// Predicate drops belong to this outcome, never to the condition's parent
848    /// scope. A consumer that installs no frame simply discards these records.
849    pub dropped: BTreeMap<ReferencePath, Type>,
850}
851
852impl NarrowEnv {
853    pub fn new() -> Self {
854        Self::default()
855    }
856
857    pub fn insert(&mut self, path: ReferencePath, view: NarrowedView) -> Option<NarrowedView> {
858        self.dropped.remove(&path);
859        self.views.insert(path, view)
860    }
861
862    pub fn extend_env(&mut self, other: Self) {
863        self.dropped.extend(other.dropped);
864        self.extend(other.views);
865    }
866
867    pub fn clear(&mut self) {
868        self.views.clear();
869        self.dropped.clear();
870    }
871}
872
873impl std::ops::Deref for NarrowEnv {
874    type Target = BTreeMap<ReferencePath, NarrowedView>;
875    fn deref(&self) -> &Self::Target {
876        &self.views
877    }
878}
879
880impl std::ops::DerefMut for NarrowEnv {
881    fn deref_mut(&mut self) -> &mut Self::Target {
882        &mut self.views
883    }
884}
885
886impl IntoIterator for NarrowEnv {
887    type Item = (ReferencePath, NarrowedView);
888    type IntoIter = std::collections::btree_map::IntoIter<ReferencePath, NarrowedView>;
889    fn into_iter(self) -> Self::IntoIter {
890        self.views.into_iter()
891    }
892}
893
894impl<'a> IntoIterator for &'a NarrowEnv {
895    type Item = (&'a ReferencePath, &'a NarrowedView);
896    type IntoIter = std::collections::btree_map::Iter<'a, ReferencePath, NarrowedView>;
897    fn into_iter(self) -> Self::IntoIter {
898        self.views.iter()
899    }
900}
901
902impl Extend<(ReferencePath, NarrowedView)> for NarrowEnv {
903    fn extend<I: IntoIterator<Item = (ReferencePath, NarrowedView)>>(&mut self, iter: I) {
904        for (path, view) in iter {
905            self.insert(path, view);
906        }
907    }
908}
909
910impl FromIterator<(ReferencePath, NarrowedView)> for NarrowEnv {
911    fn from_iter<I: IntoIterator<Item = (ReferencePath, NarrowedView)>>(iter: I) -> Self {
912        let mut env = Self::new();
913        env.extend(iter);
914        env
915    }
916}
917
918/// Nesting order for the wrap-the-body-in-one-region-per-path loops: longest chain
919/// first, so every path is wrapped *inside* the prefixes its source reads. Ties break
920/// on the path itself, making this a total order — the emitted nesting must not depend
921/// on the order the caller happened to iterate its env in.
922pub fn wrap_order(a: &ReferencePath, b: &ReferencePath) -> std::cmp::Ordering {
923    b.chain.len().cmp(&a.chain.len()).then_with(|| a.cmp(b))
924}
925
926/// Compares only `narrowed_ty` per path — `binding` and `source` differ
927/// across mints even when the narrowing is unchanged.
928pub fn envs_per_path_narrowed_ty_equal(a: &NarrowEnv, b: &NarrowEnv) -> bool {
929    if a.len() != b.len() {
930        return false;
931    }
932    for (path, a_view) in a {
933        match b.get(path) {
934            Some(b_view) if a_view.narrowed_ty == b_view.narrowed_ty => continue,
935            _ => return false,
936        }
937    }
938    true
939}
940
941/// JS-truthiness classification of a single (non-union) type: which runtime
942/// test decides truthiness for values of that member.
943#[derive(Clone, Copy, Debug, PartialEq, Eq)]
944pub enum TruthinessClass {
945    AlwaysTruthy,
946    AlwaysFalsy,
947    /// Falsy iff `0`, `-0`, or `NaN`. Includes number enums (may hold `0`).
948    NumberLike,
949    /// Falsy iff `""`. Includes string enums (may hold `""`).
950    StringLike,
951    /// Falsy iff `false`.
952    BooleanLike,
953    /// Falsy iff `0n`.
954    BigIntLike,
955    /// Erased at compile time — needs the full runtime dispatch.
956    Dynamic,
957}
958
959pub fn truthiness_class(member: &Type) -> TruthinessClass {
960    use TruthinessClass::*;
961    match member.peel() {
962        Type::Null | Type::Undefined => AlwaysFalsy,
963        Type::Boolean => BooleanLike,
964        Type::BooleanLiteral(true) => AlwaysTruthy,
965        Type::BooleanLiteral(false) => AlwaysFalsy,
966        Type::Number => NumberLike,
967        Type::NumberLiteral(n) => {
968            if n.0 == 0.0 || n.0.is_nan() {
969                AlwaysFalsy
970            } else {
971                AlwaysTruthy
972            }
973        }
974        Type::String => StringLike,
975        Type::StringLiteral(s) => {
976            if s.is_empty() {
977                AlwaysFalsy
978            } else {
979                AlwaysTruthy
980            }
981        }
982        Type::BigInt => BigIntLike,
983        Type::BigIntLiteral(digits) => {
984            if digits == "0" {
985                AlwaysFalsy
986            } else {
987                AlwaysTruthy
988            }
989        }
990        Type::NumberEnum {
991            member: Some(member),
992            ..
993        } => truthiness_class(&Type::NumberLiteral(member.value)),
994        Type::StringEnum {
995            member: Some(member),
996            ..
997        } => truthiness_class(&Type::StringLiteral(member.value.clone())),
998        Type::NumberEnum { .. } => NumberLike,
999        Type::StringEnum { .. } => StringLike,
1000        // `{}` admits every value but `null` and `undefined`, falsy primitives
1001        // included.
1002        empty if is_empty_object(empty) => Dynamic,
1003        Type::Object { .. }
1004        | Type::Array(_)
1005        | Type::Tuple(_)
1006        | Type::Function { .. }
1007        | Type::Uint8Array
1008        | Type::InterfaceRef { .. }
1009        | Type::ClassRef { .. } => AlwaysTruthy,
1010        _ => Dynamic,
1011    }
1012}
1013
1014/// Whether `ty` is `{}`, the object type with no members.
1015fn is_empty_object(ty: &Type) -> bool {
1016    matches!(ty.peel(), Type::Object { fields, index } if fields.is_empty() && index.is_none())
1017}
1018
1019/// Whether every member of `ty` is always truthy or always falsy, so a
1020/// truthiness test that rules a member out rules out every value it holds.
1021pub fn has_known_truthiness(ty: &Type) -> bool {
1022    union_members(ty).into_iter().all(|member| {
1023        matches!(
1024            truthiness_class(member),
1025            TruthinessClass::AlwaysTruthy | TruthinessClass::AlwaysFalsy
1026        )
1027    })
1028}
1029
1030/// The type of `!value` for a `value` of type `ty`, as TypeScript has it:
1031/// `false` when every value it holds is truthy, `true` when every one is
1032/// falsy, and `boolean` otherwise.
1033pub fn negation_type(ty: &Type) -> Type {
1034    let classes: Vec<TruthinessClass> = union_members(ty)
1035        .into_iter()
1036        .map(truthiness_class)
1037        .collect();
1038    if classes
1039        .iter()
1040        .all(|class| *class == TruthinessClass::AlwaysTruthy)
1041    {
1042        return Type::BooleanLiteral(false);
1043    }
1044    if classes
1045        .iter()
1046        .all(|class| *class == TruthinessClass::AlwaysFalsy)
1047    {
1048        return Type::BooleanLiteral(true);
1049    }
1050    Type::Boolean
1051}
1052
1053pub(super) fn union_members(ty: &Type) -> Vec<&Type> {
1054    match ty.peel() {
1055        Type::Union(members) => members.iter().collect(),
1056        // `ty` itself, not its peel: peeling drops a `readonly` wrapper.
1057        _ => vec![ty],
1058    }
1059}
1060
1061/// The type of `x` where `x` is known truthy: drop `null` and falsy literal
1062/// types per member. Falsy-capable base types stay whole (matching TS, which
1063/// doesn't invent a "non-empty string" type).
1064pub fn truthy_part(ty: &Type) -> Type {
1065    if let Type::Refined {
1066        original,
1067        ty: shape,
1068    } = ty.without_aliases()
1069    {
1070        return preserve_refinement(original, truthy_part(shape));
1071    }
1072    // `boolean` is exactly `true | false` (spec §1.2), so its truthy part is `true`.
1073    let kept: Vec<Type> = union_members(ty)
1074        .into_iter()
1075        .filter(|m| truthiness_class(m) != TruthinessClass::AlwaysFalsy)
1076        .map(|m| match m.without_aliases() {
1077            Type::Boolean => Type::BooleanLiteral(true),
1078            _ => m.clone(),
1079        })
1080        .collect();
1081    Type::union(kept)
1082}
1083
1084/// Whether no value of `ty` is falsy.
1085pub fn is_never_falsy(ty: &Type) -> bool {
1086    union_members(ty)
1087        .into_iter()
1088        .all(|m| truthiness_class(m) == TruthinessClass::AlwaysTruthy)
1089}
1090
1091/// The type of `x` where `x` is known falsy: keep `null` and falsy literals,
1092/// collapse `string` to `""` and `boolean` to `false`, drop never-falsy
1093/// reference types. `number` stays `number` — a `0` literal would be unsound
1094/// for `NaN`/`-0`.
1095pub fn falsy_part(ty: &Type) -> Type {
1096    if let Type::Refined {
1097        original,
1098        ty: shape,
1099    } = ty.without_aliases()
1100    {
1101        return preserve_refinement(original, falsy_part(shape));
1102    }
1103    let kept: Vec<Type> = union_members(ty)
1104        .into_iter()
1105        .filter_map(|m| match truthiness_class(m) {
1106            TruthinessClass::AlwaysTruthy => None,
1107            // `{}` has no definitely falsy part, which is all TypeScript keeps
1108            // of a member whose truthiness is unknown.
1109            TruthinessClass::Dynamic if is_empty_object(m) => None,
1110            TruthinessClass::StringLike => Some(match m.peel() {
1111                Type::String => Type::StringLiteral(String::new()),
1112                _ => m.clone(),
1113            }),
1114            _ => Some(match m.without_aliases() {
1115                Type::Boolean => Type::BooleanLiteral(false),
1116                _ => m.clone(),
1117            }),
1118        })
1119        .collect();
1120    Type::union(kept)
1121}
1122
1123/// True when every falsy value `ty` can hold is `null` — i.e. a truthiness
1124/// test is exactly a null test, so the false branch may assert `EQ_NULL`.
1125pub fn falsy_values_are_only_null(ty: &Type) -> bool {
1126    union_members(ty).into_iter().all(|m| {
1127        matches!(m.peel(), Type::Null) || truthiness_class(m) == TruthinessClass::AlwaysTruthy
1128    })
1129}
1130
1131pub fn type_matches_facts(ty: &Type, facts: TypeFacts) -> bool {
1132    // Aliases are transparent for type identity — peel before matching so an
1133    // aliased primitive/union (e.g. `type NS = number | string`) classifies the
1134    // same as its inline spelling.
1135    let ty = ty.peel();
1136    // TRUTHY/FALSY subsume the null tests (truthy ⇒ non-null), so they are
1137    // checked first: `TRUTHY | NE_NULL` filters by truthiness, bare `NE_NULL`
1138    // stays a pure null test.
1139    if facts.contains(TypeFacts::TRUTHY) {
1140        return truthiness_class(ty) != TruthinessClass::AlwaysFalsy;
1141    }
1142    if facts.contains(TypeFacts::FALSY) {
1143        return truthiness_class(ty) != TruthinessClass::AlwaysTruthy;
1144    }
1145    if facts.contains(TypeFacts::NE_NULL) || facts.contains(TypeFacts::NE_UNDEFINED) {
1146        return !(facts.contains(TypeFacts::NE_NULL) && matches!(ty, Type::Null)
1147            || facts.contains(TypeFacts::NE_UNDEFINED) && matches!(ty, Type::Undefined));
1148    }
1149    // `void` is not `undefined` here: `intersect_with` narrows it on a
1150    // `typeof` test, and `subtract` never rules it out.
1151    if facts.contains(TypeFacts::IS_UNDEFINED) {
1152        return matches!(ty, Type::Undefined);
1153    }
1154    if facts.contains(TypeFacts::EQ_UNDEFINED) {
1155        return matches!(ty, Type::Undefined);
1156    }
1157    if facts.contains(TypeFacts::EQ_NULL) {
1158        return matches!(ty, Type::Null);
1159    }
1160    if facts.contains(TypeFacts::IS_BIGINT) {
1161        return matches!(ty, Type::BigInt);
1162    }
1163    if facts.contains(TypeFacts::IS_NUMBER) {
1164        // An enum member's `typeof` is its underlying primitive, as in TypeScript.
1165        return matches!(
1166            ty,
1167            Type::Number | Type::NumberLiteral(_) | Type::NumberEnum { .. }
1168        );
1169    }
1170    if facts.contains(TypeFacts::IS_STRING) {
1171        return matches!(
1172            ty,
1173            Type::String | Type::StringLiteral(_) | Type::StringEnum { .. }
1174        );
1175    }
1176    if facts.contains(TypeFacts::IS_BOOLEAN) {
1177        return matches!(ty, Type::Boolean | Type::BooleanLiteral(_));
1178    }
1179    if facts.contains(TypeFacts::IS_ARRAY) {
1180        return matches!(ty, Type::Array(_));
1181    }
1182    if facts.contains(TypeFacts::IS_FUNCTION) {
1183        return matches!(ty, Type::Function { .. });
1184    }
1185    if facts.contains(TypeFacts::IS_OBJECT) {
1186        return is_typeof_object(ty);
1187    }
1188    false
1189}
1190
1191/// Does `typeof` answer `"object"` for a value of `ty`?
1192///
1193/// Exhaustive on purpose. `"object"` is the complement of the primitive tags,
1194/// so a type constructor with no arm here is a value with *no* `typeof` at all —
1195/// every one of the five tags folds to `false`, which is unsatisfiable and
1196/// silent. Matching exhaustively makes a new [`Type`] variant fail to compile
1197/// until someone decides its tag.
1198fn is_typeof_object(ty: &Type) -> bool {
1199    match ty {
1200        // A union reaches here from the tuple-element rollup, where the
1201        // question is whether the whole element answers the tag.
1202        Type::Union(members) => members.iter().all(is_typeof_object),
1203
1204        Type::Object { .. }
1205        | Type::Array(_)
1206        | Type::Tuple(_)
1207        | Type::InterfaceRef { .. }
1208        | Type::ClassRef { .. }
1209        | Type::Uint8Array
1210        // `typeof null === "object"` — the JS quirk.
1211        | Type::Null => true,
1212
1213        Type::Undefined
1214        | Type::Number
1215        | Type::NumberLiteral(_)
1216        | Type::NumberEnum { .. }
1217        | Type::String
1218        | Type::StringLiteral(_)
1219        | Type::StringEnum { .. }
1220        | Type::Boolean
1221        | Type::BooleanLiteral(_)
1222        | Type::BigInt
1223        | Type::BigIntLiteral(_)
1224        | Type::Function { .. } => false,
1225
1226        // Not classifiable, for four different reasons: no value at all
1227        // (`Void`, `Never`), a diagnostic already reported (`Error`), erased at
1228        // runtime or bodyless (`TypeVar`, `GenericParam`, `Unknown`, and a
1229        // recursive alias's back-edge `AliasRef`). `fold_typeof_tag` bails on
1230        // the erased set before it ever asks, so `false` here only ever means
1231        // "narrows nothing", never "answers no tag".
1232        Type::Void
1233        | Type::Never
1234        | Type::Error
1235        | Type::Unknown
1236        | Type::TypeVar(_)
1237        | Type::GenericParam { .. }
1238        | Type::AliasRef { .. } => false,
1239
1240        // Peeled by the caller.
1241        Type::Alias { .. } | Type::Refined { .. } | Type::Readonly(_) => false,
1242    }
1243}
1244
1245/// Keep only the union members of `ty` that match `facts`. For a
1246/// non-union `ty`, returns `ty` if matched or [`Type::Error`] if not.
1247pub fn intersect_with(ty: &Type, facts: TypeFacts) -> Type {
1248    if let Type::Refined {
1249        original,
1250        ty: shape,
1251    } = ty.without_aliases()
1252    {
1253        return preserve_refinement(original, intersect_with(shape, facts));
1254    }
1255    let peeled = ty.peel();
1256    if let Some(asserted) = asserted_type_for_facts(facts) {
1257        match peeled {
1258            Type::Unknown => return asserted,
1259            Type::GenericParam { .. } => {
1260                return Type::Refined {
1261                    original: Box::new(ty.clone()),
1262                    ty: Box::new(asserted),
1263                };
1264            }
1265            _ => {}
1266        }
1267    }
1268    match peeled {
1269        Type::Union(members) => Type::union(
1270            members
1271                .iter()
1272                .map(|m| intersect_with(m, facts))
1273                .filter(|ty| !matches!(ty, Type::Error))
1274                .collect(),
1275        ),
1276        // An explicit typeof test supplies the runtime fact that a void
1277        // value's static type alone cannot establish.
1278        Type::Void if facts.contains(TypeFacts::IS_UNDEFINED) => Type::Undefined,
1279        // `boolean` is `true | false`, and truthiness splits it.
1280        Type::Boolean if facts == TypeFacts::TRUTHY => Type::BooleanLiteral(true),
1281        Type::Boolean if facts == TypeFacts::FALSY => Type::BooleanLiteral(false),
1282        _ if type_matches_facts(peeled, facts) => ty.clone(),
1283        _ if matches!(peeled, Type::Unknown) => Type::Unknown,
1284        _ => Type::Error,
1285    }
1286}
1287
1288pub fn subtract(ty: &Type, facts: TypeFacts) -> Type {
1289    if let Type::Refined {
1290        original,
1291        ty: shape,
1292    } = ty.without_aliases()
1293    {
1294        return preserve_refinement(original, subtract(shape, facts));
1295    }
1296    let peeled = ty.peel();
1297    // false-branch on `unknown` is still `unknown` — we can't pin
1298    // down "unknown minus T" statically.
1299    if matches!(peeled, Type::Unknown) {
1300        return Type::Unknown;
1301    }
1302    // A `void` result may hold any value, so no test rules it out: `typeof
1303    // v !== "undefined"` keeps it, though `typeof v === "undefined"` may
1304    // narrow it to `undefined`.
1305    let ruled_out =
1306        |member: &Type| !matches!(member.peel(), Type::Void) && type_matches_facts(member, facts);
1307    match peeled {
1308        Type::Union(members) => {
1309            let kept: Vec<Type> = members.iter().filter(|m| !ruled_out(m)).cloned().collect();
1310            Type::union(kept)
1311        }
1312        _ if !ruled_out(peeled) => ty.clone(),
1313        _ => Type::Error,
1314    }
1315}
1316
1317pub(super) fn with_source_refinement(source: &Type, shape: Type) -> Type {
1318    match source.without_aliases() {
1319        Type::Refined { original, .. } => preserve_refinement(original, shape),
1320        _ => shape,
1321    }
1322}
1323
1324pub(super) fn preserve_refinement(original: &Type, shape: Type) -> Type {
1325    if matches!(shape, Type::Error | Type::Never) {
1326        return shape;
1327    }
1328    Type::Refined {
1329        original: Box::new(original.clone()),
1330        ty: Box::new(shape),
1331    }
1332}
1333
1334/// Strip covered literal values from `ty`. When the residual is `Type::Never`,
1335/// all discriminant values were covered by `case` labels.
1336///
1337/// - **Union**: filter out covered literal members, keeping the rest (`number`
1338///   in `number | "a"`); collapses to `Never` when all drop.
1339/// - **Single literal**: `Never` if covered, unchanged otherwise.
1340/// - **Anything else**: returned unchanged.
1341pub fn subtract_literals(ty: &Type, covered: &BTreeSet<LiteralValue>) -> Type {
1342    if let Type::Refined {
1343        original,
1344        ty: shape,
1345    } = ty.without_aliases()
1346    {
1347        return preserve_refinement(original, subtract_literals(shape, covered));
1348    }
1349    match ty.peel() {
1350        Type::Union(members) => {
1351            let kept: Vec<Type> = members
1352                .iter()
1353                .map(|m| subtract_literals(m, covered))
1354                .collect();
1355            Type::union(kept)
1356        }
1357        // `boolean` is `true | false`, so `case true:` leaves `false`.
1358        Type::Boolean => {
1359            let remaining: Vec<Type> = [false, true]
1360                .into_iter()
1361                .filter(|b| !covered.contains(&LiteralValue::Boolean(*b)))
1362                .map(Type::BooleanLiteral)
1363                .collect();
1364            Type::union(remaining)
1365        }
1366        single @ (Type::StringLiteral(_)
1367        | Type::NumberLiteral(_)
1368        | Type::BooleanLiteral(_)
1369        | Type::BigIntLiteral(_)
1370        | Type::Null
1371        | Type::Undefined) => match unit_value(single) {
1372            Some(lit) if covered.contains(&lit) => Type::Never,
1373            _ => ty.clone(),
1374        },
1375        _ => ty.clone(),
1376    }
1377}
1378
1379/// Map a [`TypeFacts`] mask to the concrete `Type` it asserts when narrowing
1380/// `unknown`. Returns `None` when facts don't pin a single type (TRUTHY,
1381/// IS_OBJECT span multiple types).
1382fn asserted_type_for_facts(facts: TypeFacts) -> Option<Type> {
1383    if facts.contains(TypeFacts::IS_BIGINT) {
1384        return Some(Type::BigInt);
1385    }
1386    if facts.contains(TypeFacts::IS_NUMBER) {
1387        return Some(Type::Number);
1388    }
1389    if facts.contains(TypeFacts::IS_STRING) {
1390        return Some(Type::String);
1391    }
1392    if facts.contains(TypeFacts::IS_BOOLEAN) {
1393        return Some(Type::Boolean);
1394    }
1395    if facts.contains(TypeFacts::IS_ARRAY) {
1396        return Some(Type::Array(Box::new(Type::Unknown)));
1397    }
1398    if facts.contains(TypeFacts::EQ_NULL) {
1399        return Some(Type::Null);
1400    }
1401    if facts.contains(TypeFacts::EQ_UNDEFINED) || facts.contains(TypeFacts::IS_UNDEFINED) {
1402        return Some(Type::Undefined);
1403    }
1404    // IS_OBJECT admits arrays, objects, and null (the JS typeof-null quirk);
1405    // no single concrete type, so caller keeps `Unknown`.
1406    None
1407}
1408
1409/// Join two branch post-environments at a control-flow merge point:
1410///
1411/// - Paths narrowed in **both** branches: joined type is `Type::union` of the two.
1412/// - Paths narrowed in only one branch: dropped.
1413/// - Paths a branch *extended* by assignment (`o` assigned, `o.f` narrowed):
1414///   dropped — the write can invalidate everything below it.
1415///
1416/// An assignment to the narrowed path *itself* does not drop it: each branch's
1417/// env already carries what that branch's assignment left behind
1418/// (`install_assignment_narrowing` records the assigned type), so the union of
1419/// the two is exactly the post-join type. That is what makes
1420/// `if (s === null) { s = "x"; }` leave `s: string` behind.
1421///
1422/// Assumes both branches contributed; caller handles the unreachable-branch case.
1423pub fn union_envs(
1424    a_narrowings: NarrowEnv,
1425    a_assigned: BTreeSet<ReferencePath>,
1426    b_narrowings: NarrowEnv,
1427    b_assigned: BTreeSet<ReferencePath>,
1428) -> (NarrowEnv, BTreeSet<ReferencePath>) {
1429    let mut joined_narrowings = NarrowEnv::new();
1430    for (path, a_view) in &a_narrowings {
1431        if let Some(b_view) = b_narrowings.get(path) {
1432            let joined_ty = join_flow_types(&a_view.narrowed_ty, &b_view.narrowed_ty);
1433            // A ruled-out view has no shadow, so the join reads through the other.
1434            let a_view = if is_ruled_out(&a_view.narrowed_ty) {
1435                b_view
1436            } else {
1437                a_view
1438            };
1439            joined_narrowings.insert(
1440                path.clone(),
1441                NarrowedView {
1442                    narrowed_ty: joined_ty,
1443                    // Facts are per-branch; the join can't preserve
1444                    // them precisely. The downstream slim-slice
1445                    // sites only consume `narrowed_ty`, so EMPTY is
1446                    // sound.
1447                    facts: TypeFacts::EMPTY,
1448                    excluded_literals: BTreeSet::new(),
1449                    // Reuse `a`'s binding + source. The join
1450                    // typically gets dropped by the assigned-paths
1451                    // filter below; if it survives, the binding /
1452                    // source remain valid since both branches saw
1453                    // the same path.
1454                    binding: a_view.binding.clone(),
1455                    source: a_view.source,
1456                },
1457            );
1458        }
1459    }
1460
1461    for (path, ty) in a_narrowings.dropped.iter().chain(&b_narrowings.dropped) {
1462        let left_ty = a_narrowings
1463            .dropped
1464            .get(path)
1465            .or_else(|| a_narrowings.get(path).map(|view| &view.narrowed_ty));
1466        let right_ty = b_narrowings
1467            .dropped
1468            .get(path)
1469            .or_else(|| b_narrowings.get(path).map(|view| &view.narrowed_ty));
1470        if left_ty == Some(ty) && right_ty == Some(ty) {
1471            joined_narrowings.dropped.insert(path.clone(), ty.clone());
1472        }
1473    }
1474
1475    let mut joined_assigned = a_assigned;
1476    joined_assigned.extend(b_assigned);
1477
1478    joined_narrowings.dropped.retain(|key, _| {
1479        !joined_assigned
1480            .iter()
1481            .any(|assigned| assigned.is_prefix_of(key))
1482    });
1483    joined_narrowings.retain(|key, _| {
1484        !joined_assigned
1485            .iter()
1486            .any(|assigned| assigned != key && assigned.is_prefix_of(key))
1487    });
1488
1489    (joined_narrowings, joined_assigned)
1490}
1491
1492/// The tuple position a number names, if it names one.
1493pub(super) fn tuple_position(index: f64) -> Option<usize> {
1494    (index >= 0.0 && index.fract() == 0.0 && index <= u32::MAX as f64).then_some(index as usize)
1495}
1496
1497/// The type of a view whose guard ruled out every value. Narrowing yields
1498/// `Error` when nothing is left; a view keeps it, rather than `never`, so
1499/// codegen gives the path no shadow local, and a read of the path turns it
1500/// into `never` where `Inferer::rules_out_to_never` allows.
1501pub const RULED_OUT: Type = Type::Error;
1502
1503/// Whether a narrowed type is [`RULED_OUT`].
1504pub fn is_ruled_out(ty: &Type) -> bool {
1505    matches!(ty, Type::Error)
1506}
1507
1508/// Flow joins collapse a literal already covered by a broad primitive. Keep
1509/// authored unions unchanged: their overlap is meaningful to JSON diagnostics.
1510pub(super) fn join_flow_types(left: &Type, right: &Type) -> Type {
1511    // A ruled-out side holds no value, so it adds nothing to the other.
1512    if is_ruled_out(left) {
1513        return right.clone();
1514    }
1515    if is_ruled_out(right) {
1516        return left.clone();
1517    }
1518    let joined = Type::union(vec![left.clone(), right.clone()]);
1519    let Type::Union(mut members) = joined else {
1520        return joined;
1521    };
1522    let has_number = members
1523        .iter()
1524        .any(|ty| matches!(ty.without_aliases(), Type::Number));
1525    let has_string = members
1526        .iter()
1527        .any(|ty| matches!(ty.without_aliases(), Type::String));
1528    members.retain(|ty| match ty.without_aliases() {
1529        Type::NumberLiteral(_) => !has_number,
1530        Type::StringLiteral(_) => !has_string,
1531        _ => true,
1532    });
1533    Type::union(members)
1534}
1535
1536/// Recurses into `Type::Union` members only — composite slots have fixed runtime
1537/// classification regardless of generic contents, so their type arguments are
1538/// not walked either (`Box<T> = number` classifies as a number whatever `T` is).
1539///
1540/// Peeled: an alias of a type parameter is erased just as the parameter is, and
1541/// the caller uses this as the escape hatch that keeps a `typeof` guard on an
1542/// erased operand from folding to a constant.
1543pub fn has_erased_member(ty: &Type) -> bool {
1544    match ty.peel() {
1545        Type::TypeVar(_) | Type::GenericParam { .. } => true,
1546        Type::Union(members) => members.iter().any(has_erased_member),
1547        _ => false,
1548    }
1549}
1550
1551/// Every type is condition-compatible under JS truthiness except `unknown`
1552/// (requires explicit narrowing first, spec §2.11) and the value-less bare
1553/// `void`. A union with another value-bearing alternative is legal; its void
1554/// member remains dynamic. `never` counts, as in TypeScript: either no value
1555/// reaches the test (a call that throws, a local after an exhausted `else if`
1556/// chain), or the value is one a `never[]` an alias filled holds, which tests
1557/// as it would anywhere. `Error` is accepted to silence cascades.
1558pub fn condition_compatible(ty: &Type) -> bool {
1559    !matches!(ty.peel(), Type::Unknown | Type::Void)
1560}
1561
1562pub fn facts_for_target_type(target: &Type) -> TypeFacts {
1563    match target.peel() {
1564        Type::Number | Type::NumberLiteral(_) => TypeFacts::IS_NUMBER,
1565        Type::String | Type::StringLiteral(_) => TypeFacts::IS_STRING,
1566        Type::Boolean | Type::BooleanLiteral(_) => TypeFacts::IS_BOOLEAN,
1567        Type::Null => TypeFacts::EQ_NULL,
1568        Type::Undefined => TypeFacts::EQ_UNDEFINED,
1569        Type::BigInt => TypeFacts::IS_BIGINT,
1570        Type::Array(_) => TypeFacts::IS_ARRAY,
1571        _ => TypeFacts::EMPTY,
1572    }
1573}
1574
1575#[cfg(test)]
1576mod analyzer_tests {
1577    use super::*;
1578
1579    #[test]
1580    fn typeof_undefined_splits_void_without_assuming_its_truthiness() {
1581        let ty = Type::union(vec![Type::Boolean, Type::Void]);
1582        assert_eq!(
1583            intersect_with(&ty, TypeFacts::IS_UNDEFINED),
1584            Type::Undefined
1585        );
1586        assert_eq!(subtract(&ty, TypeFacts::IS_UNDEFINED), ty);
1587        assert_eq!(truthiness_class(&Type::Void), TruthinessClass::Dynamic);
1588        assert!(!type_matches_facts(&Type::Void, TypeFacts::EQ_UNDEFINED));
1589    }
1590
1591    #[test]
1592    fn undefined_facts_are_distinct_from_null() {
1593        let ty = Type::union(vec![Type::String, Type::Null, Type::Undefined]);
1594        assert_eq!(
1595            strip_null(&ty),
1596            Type::union(vec![Type::String, Type::Undefined])
1597        );
1598        assert_eq!(
1599            strip_undefined(&ty),
1600            Type::union(vec![Type::String, Type::Null])
1601        );
1602        assert_eq!(strip_nullish(&ty), Type::String);
1603        assert_eq!(
1604            intersect_with(&ty, TypeFacts::EQ_UNDEFINED),
1605            Type::Undefined
1606        );
1607        assert_eq!(
1608            intersect_with(&Type::Unknown, TypeFacts::EQ_UNDEFINED),
1609            Type::Undefined
1610        );
1611        assert_eq!(
1612            intersect_with(&ty, TypeFacts::NE_NULL | TypeFacts::NE_UNDEFINED),
1613            Type::String
1614        );
1615        assert_eq!(truthy_part(&ty), Type::String);
1616        assert_eq!(
1617            truthiness_class(&Type::Undefined),
1618            TruthinessClass::AlwaysFalsy
1619        );
1620        assert!(!type_matches_facts(&Type::Undefined, TypeFacts::IS_OBJECT));
1621    }
1622
1623    #[test]
1624    fn presence_keeps_optional_read_undefined() {
1625        let ty = Type::Object {
1626            fields: BTreeMap::from([("value".into(), crate::ObjectField::optional(Type::Number))]),
1627            index: None,
1628        };
1629        let (present, absent) = narrow_field_presence(&ty, "value", &|_, _| None);
1630        assert_eq!(present, ty);
1631        assert_eq!(absent, ty);
1632    }
1633
1634    #[test]
1635    fn refined_literal_residual_retains_generic_identity() {
1636        let original = Type::GenericParam {
1637            id: 1,
1638            name: "T".into(),
1639        };
1640        let shape = Type::union(vec![
1641            Type::StringLiteral("a".into()),
1642            Type::StringLiteral("b".into()),
1643        ]);
1644        let refined = preserve_refinement(&original, shape);
1645        let covered = BTreeSet::from([LiteralValue::String("a".into())]);
1646        assert_eq!(
1647            subtract_literals(&refined, &covered),
1648            preserve_refinement(&original, Type::StringLiteral("b".into()))
1649        );
1650    }
1651
1652    #[test]
1653    fn strip_null_from_nullable_string() {
1654        let ty = Type::union(vec![Type::String, Type::Null]);
1655        assert_eq!(strip_null(&ty), Type::String);
1656    }
1657
1658    #[test]
1659    fn strip_null_from_non_nullable_is_identity() {
1660        assert_eq!(strip_null(&Type::String), Type::String);
1661        assert_eq!(strip_null(&Type::Number), Type::Number);
1662    }
1663
1664    #[test]
1665    fn strip_null_from_null_is_error() {
1666        assert_eq!(strip_null(&Type::Null), Type::Error);
1667    }
1668
1669    #[test]
1670    fn strip_null_from_multi_member_union() {
1671        let ty = Type::union(vec![Type::String, Type::Number, Type::Null]);
1672        let stripped = strip_null(&ty);
1673        // Type::union canonicalizes; should be String | Number.
1674        match stripped {
1675            Type::Union(members) => assert_eq!(members.len(), 2),
1676            _ => panic!("expected Union, got {stripped:?}"),
1677        }
1678    }
1679
1680    #[test]
1681    fn intersect_with_is_number_filters_union() {
1682        let ty = Type::union(vec![Type::Number, Type::String, Type::Boolean]);
1683        let kept = intersect_with(&ty, TypeFacts::IS_NUMBER);
1684        assert_eq!(kept, Type::Number);
1685    }
1686
1687    #[test]
1688    fn subtract_is_number_keeps_others() {
1689        let ty = Type::union(vec![Type::Number, Type::String, Type::Boolean]);
1690        let kept = subtract(&ty, TypeFacts::IS_NUMBER);
1691        match kept {
1692            Type::Union(members) => {
1693                assert_eq!(members.len(), 2);
1694                assert!(members.contains(&Type::String));
1695                assert!(members.contains(&Type::Boolean));
1696            }
1697            _ => panic!("expected Union, got {kept:?}"),
1698        }
1699    }
1700
1701    #[test]
1702    fn intersect_with_truthy_strips_null() {
1703        let ty = Type::union(vec![Type::String, Type::Null]);
1704        let kept = intersect_with(&ty, TypeFacts::TRUTHY);
1705        assert_eq!(kept, Type::String);
1706    }
1707
1708    #[test]
1709    fn subtract_truthy_keeps_only_null() {
1710        let ty = Type::union(vec![Type::String, Type::Null]);
1711        let kept = subtract(&ty, TypeFacts::TRUTHY);
1712        assert_eq!(kept, Type::Null);
1713    }
1714
1715    #[test]
1716    fn intersect_with_no_match_yields_error() {
1717        let kept = intersect_with(&Type::String, TypeFacts::IS_NUMBER);
1718        assert_eq!(kept, Type::Error);
1719    }
1720
1721    #[test]
1722    fn facts_for_target_type_covers_primitives() {
1723        assert_eq!(facts_for_target_type(&Type::Number), TypeFacts::IS_NUMBER);
1724        assert_eq!(facts_for_target_type(&Type::String), TypeFacts::IS_STRING);
1725        assert_eq!(facts_for_target_type(&Type::Boolean), TypeFacts::IS_BOOLEAN);
1726        assert_eq!(facts_for_target_type(&Type::Null), TypeFacts::EQ_NULL);
1727    }
1728}
1729
1730#[cfg(test)]
1731mod tests {
1732    use super::*;
1733    use crate::Span;
1734
1735    #[test]
1736    fn reference_path_equality_is_structural() {
1737        let a = ReferencePath::root(BindingId::Local {
1738            name: "x".to_string(),
1739            decl_scope: ScopeId(1),
1740        });
1741        let b = ReferencePath::root(BindingId::Local {
1742            name: "x".to_string(),
1743            decl_scope: ScopeId(1),
1744        });
1745        assert_eq!(a, b);
1746    }
1747
1748    #[test]
1749    fn reference_path_distinguishes_scopes() {
1750        let a = ReferencePath::root(BindingId::Local {
1751            name: "x".to_string(),
1752            decl_scope: ScopeId(1),
1753        });
1754        let b = ReferencePath::root(BindingId::Local {
1755            name: "x".to_string(),
1756            decl_scope: ScopeId(2),
1757        });
1758        assert_ne!(a, b);
1759    }
1760
1761    #[test]
1762    fn reference_path_distinguishes_chain() {
1763        let a = ReferencePath {
1764            root: BindingId::Local {
1765                name: "x".to_string(),
1766                decl_scope: ScopeId(1),
1767            },
1768            chain: vec![PathElem::Field("foo".to_string())],
1769        };
1770        let b = ReferencePath::root(BindingId::Local {
1771            name: "x".to_string(),
1772            decl_scope: ScopeId(1),
1773        });
1774        assert_ne!(a, b);
1775    }
1776
1777    #[test]
1778    fn type_facts_or_combines() {
1779        let f = TypeFacts::TRUTHY | TypeFacts::NE_NULL;
1780        assert!(f.contains(TypeFacts::TRUTHY));
1781        assert!(f.contains(TypeFacts::NE_NULL));
1782        assert!(!f.contains(TypeFacts::EQ_NULL));
1783    }
1784
1785    #[test]
1786    fn type_facts_not_complements() {
1787        let f = TypeFacts::EQ_NULL;
1788        let flipped = !f;
1789        assert!(flipped.contains(TypeFacts::NE_NULL));
1790        assert!(!flipped.contains(TypeFacts::EQ_NULL));
1791    }
1792
1793    #[test]
1794    fn binding_id_global_uses_mangled_name() {
1795        let g = BindingId::Global(crate::mangle::package_symbol("main", "x"));
1796        if let BindingId::Global(name) = &g {
1797            assert_eq!(name.as_str(), "main#x");
1798        } else {
1799            panic!("expected Global");
1800        }
1801    }
1802
1803    #[test]
1804    fn narrowed_view_construction() {
1805        let view = NarrowedView {
1806            narrowed_ty: Type::String,
1807            facts: TypeFacts::NE_NULL,
1808            excluded_literals: BTreeSet::new(),
1809            binding: Ident {
1810                name: "#narrow_0".to_string(),
1811                span: Span::new(crate::FileId(0), 0, 1).unwrap(),
1812            },
1813            source: crate::ExprId(0),
1814        };
1815        assert_eq!(view.narrowed_ty, Type::String);
1816        assert!(view.facts.contains(TypeFacts::NE_NULL));
1817    }
1818
1819    #[test]
1820    fn cast_info_holds_kind_and_types() {
1821        let info = CastInfo {
1822            from_ty: Type::Union(vec![Type::String, Type::Null]),
1823            to_ty: Type::String,
1824            cast_kind: CastKind::NonNull,
1825        };
1826        assert!(matches!(info.cast_kind, CastKind::NonNull));
1827    }
1828
1829    #[test]
1830    fn union_discriminant_stub_returns_none() {
1831        assert!(union_discriminant(&[Type::String, Type::Null]).is_none());
1832    }
1833
1834    #[test]
1835    fn intersect_unknown_with_is_string() {
1836        assert_eq!(
1837            intersect_with(&Type::Unknown, TypeFacts::IS_STRING),
1838            Type::String
1839        );
1840    }
1841
1842    #[test]
1843    fn intersect_unknown_with_is_number() {
1844        assert_eq!(
1845            intersect_with(&Type::Unknown, TypeFacts::IS_NUMBER),
1846            Type::Number
1847        );
1848    }
1849
1850    #[test]
1851    fn intersect_unknown_with_is_boolean() {
1852        assert_eq!(
1853            intersect_with(&Type::Unknown, TypeFacts::IS_BOOLEAN),
1854            Type::Boolean
1855        );
1856    }
1857
1858    #[test]
1859    fn intersect_unknown_with_is_array_carries_unknown_element() {
1860        // `Array.isArray<T>(unknown)` narrows to `Array<unknown>`
1861        // because the element type isn't statically determined.
1862        assert_eq!(
1863            intersect_with(&Type::Unknown, TypeFacts::IS_ARRAY),
1864            Type::Array(Box::new(Type::Unknown)),
1865        );
1866    }
1867
1868    #[test]
1869    fn intersect_unknown_with_eq_null() {
1870        assert_eq!(
1871            intersect_with(&Type::Unknown, TypeFacts::EQ_NULL),
1872            Type::Null
1873        );
1874    }
1875
1876    #[test]
1877    fn intersect_unknown_with_truthy_stays_unknown() {
1878        // Truthiness doesn't pin a concrete type — unknown stays
1879        // unknown (with the TRUTHY/NE_NULL fact carried on the view).
1880        assert_eq!(
1881            intersect_with(&Type::Unknown, TypeFacts::TRUTHY),
1882            Type::Unknown
1883        );
1884    }
1885
1886    #[test]
1887    fn subtract_unknown_stays_unknown() {
1888        // False-branch of any predicate on unknown is still unknown —
1889        // we can't statically pin down "unknown minus T".
1890        assert_eq!(
1891            subtract(&Type::Unknown, TypeFacts::IS_STRING),
1892            Type::Unknown
1893        );
1894        assert_eq!(subtract(&Type::Unknown, TypeFacts::EQ_NULL), Type::Unknown);
1895        assert_eq!(subtract(&Type::Unknown, TypeFacts::TRUTHY), Type::Unknown);
1896    }
1897
1898    #[test]
1899    fn strip_null_unknown_preserves_unknown() {
1900        assert_eq!(strip_null(&Type::Unknown), Type::Unknown);
1901    }
1902
1903    #[test]
1904    fn condition_compatible_rejects_unknown() {
1905        assert!(!condition_compatible(&Type::Unknown));
1906    }
1907
1908    #[test]
1909    fn condition_compatible_accepts_js_truthiness_types() {
1910        for ty in [
1911            Type::Boolean,
1912            Type::String,
1913            Type::Number,
1914            Type::BigInt,
1915            Type::Null,
1916            Type::Array(Box::new(Type::Number)),
1917            Type::union(vec![Type::String, Type::Null]),
1918            Type::union(vec![Type::Number, Type::Void]),
1919        ] {
1920            assert!(condition_compatible(&ty), "{ty:?}");
1921        }
1922        assert!(condition_compatible(&Type::Never));
1923        assert!(!condition_compatible(&Type::Void));
1924    }
1925
1926    #[test]
1927    fn truthiness_class_table() {
1928        use TruthinessClass::*;
1929        let cases = [
1930            (Type::Null, AlwaysFalsy),
1931            (Type::Boolean, BooleanLike),
1932            (Type::Number, NumberLike),
1933            (
1934                Type::NumberLiteral(crate::types::LiteralF64(0.0)),
1935                AlwaysFalsy,
1936            ),
1937            (
1938                Type::NumberLiteral(crate::types::LiteralF64(-0.0)),
1939                AlwaysFalsy,
1940            ),
1941            (
1942                Type::NumberLiteral(crate::types::LiteralF64(3.0)),
1943                AlwaysTruthy,
1944            ),
1945            (Type::String, StringLike),
1946            (Type::StringLiteral(String::new()), AlwaysFalsy),
1947            (Type::StringLiteral("a".to_string()), AlwaysTruthy),
1948            (Type::BigInt, BigIntLike),
1949            (Type::Uint8Array, AlwaysTruthy),
1950            (Type::Array(Box::new(Type::Number)), AlwaysTruthy),
1951            (Type::Unknown, Dynamic),
1952        ];
1953        for (ty, expected) in cases {
1954            assert_eq!(truthiness_class(&ty), expected, "{ty:?}");
1955        }
1956    }
1957
1958    #[test]
1959    fn truthy_part_strips_null_and_falsy_literals() {
1960        assert_eq!(
1961            truthy_part(&Type::union(vec![Type::String, Type::Null])),
1962            Type::String
1963        );
1964        assert_eq!(
1965            truthy_part(&Type::union(vec![
1966                Type::StringLiteral(String::new()),
1967                Type::StringLiteral("a".to_string()),
1968                Type::Null,
1969            ])),
1970            Type::StringLiteral("a".to_string())
1971        );
1972        assert_eq!(truthy_part(&Type::Null), Type::Never);
1973        assert_eq!(truthy_part(&Type::Boolean), Type::BooleanLiteral(true));
1974        assert_eq!(truthy_part(&Type::Number), Type::Number);
1975    }
1976
1977    #[test]
1978    fn falsy_part_keeps_falsy_capable_members() {
1979        assert_eq!(
1980            falsy_part(&Type::union(vec![Type::String, Type::Null])),
1981            Type::union(vec![Type::StringLiteral(String::new()), Type::Null])
1982        );
1983        assert_eq!(falsy_part(&Type::Number), Type::Number);
1984        assert_eq!(falsy_part(&Type::Boolean), Type::BooleanLiteral(false));
1985        // Never-falsy references drop entirely.
1986        assert_eq!(
1987            falsy_part(&Type::union(vec![
1988                Type::Array(Box::new(Type::Number)),
1989                Type::Null
1990            ])),
1991            Type::Null
1992        );
1993        assert_eq!(
1994            falsy_part(&Type::Array(Box::new(Type::Number))),
1995            Type::Never
1996        );
1997    }
1998
1999    #[test]
2000    fn falsy_only_null_distinguishes_reference_unions() {
2001        assert!(falsy_values_are_only_null(&Type::union(vec![
2002            Type::Array(Box::new(Type::Number)),
2003            Type::Null
2004        ])));
2005        assert!(!falsy_values_are_only_null(&Type::union(vec![
2006            Type::String,
2007            Type::Null
2008        ])));
2009        assert!(falsy_values_are_only_null(&Type::Uint8Array));
2010        assert!(!falsy_values_are_only_null(&Type::Number));
2011    }
2012
2013    #[test]
2014    fn intersect_truthy_and_falsy_respect_js_truthiness() {
2015        let string_or_null = Type::union(vec![Type::String, Type::Null]);
2016        assert_eq!(
2017            intersect_with(&string_or_null, TypeFacts::TRUTHY | TypeFacts::NE_NULL),
2018            Type::String
2019        );
2020        // `""` is falsy but not null — the falsy side keeps `string`.
2021        assert_eq!(
2022            intersect_with(&string_or_null, TypeFacts::FALSY | TypeFacts::EQ_NULL),
2023            string_or_null
2024        );
2025        let arr_or_null = Type::union(vec![Type::Array(Box::new(Type::Number)), Type::Null]);
2026        assert_eq!(
2027            intersect_with(&arr_or_null, TypeFacts::FALSY | TypeFacts::EQ_NULL),
2028            Type::Null
2029        );
2030        // Bare NE_NULL stays a pure null test: `x !== null` keeps `""`.
2031        assert_eq!(
2032            intersect_with(&string_or_null, TypeFacts::NE_NULL),
2033            Type::String
2034        );
2035    }
2036}