镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Design Meeting Notes, 2026-08-27 #64113

Description

Negated Types (not T)

#63926

  • If you think about a venn diagram of A and B overlapping where A & B is the overlap, not A is all the area outside of A, not (A & B) is the inverse color of that original overlap.
  • Fresh object types are closed -
  • Talked about this before, have a feeling it's more relevant today.
  • Like what?
    • Something like a --noUnusedReturnedValues

      • Don't want to forget

      • People use void, but this accepts Promises that need to be awaited.

      • Can have an ignore to help here.

        function ignore(value: not PromiseLike<unknown>): void {}
    • `foo${string & not "reserved-key"}`

      • Record<`on${string & not keyof EventNames}`, unknown>
    • Can also do narrowing for carve outs of infinite sets.

      function divide(a: number, b: number & not 0): number {
          return a / b;
      }
      
      function doStuff(someValue: number) {
          if (someValue !== 0) {
              divide(10, x); // Okay
      
              // (come back to this below)
              const x = someValue;
              divide(10, x); // ...
          }
          else {
              divide(10, someValue); // Should error!
          }
      }
      • Okay, but how does that work with aliases
        • Currently an alias loses that information.
        • That is super surprising.
  • What are the old issues that this avoids?
    • We had a prototype that swapped out our fact-tracking system in control flow analysis and replaced it with negated type tracking.
    • But this was computationally intensive.
    • This PR introduces negations only for specific types of control flow narrowing in the false branch where we needed to track that info.
      • Only requests negation when a use-site of a binding needs negation.
      • Done based on the contextual type at that use-site.
      • We actually have similar logic here for when we narrow generic parameters in the body of a function!
        • Very similar precedent here.
  • Do you want the most precise type to be inferred
    • e.g. type narrowing conditionals (come back to this?)
    • We also infer type predicates for you now.
  • Negated types seem useful over the set of primitives, and often works well enough in the object hierarchy, but doesn't really make sense to have a provable negated object type.
    • An object doesn't necessarily exclude a Promise or a Function.
    • But all sorts of checks like this are all heuristics-based.
  • Could have a switch to check for negations?
    • But is this a strictness thing?
    • Feels like no - weird.
  • Does this feel consistent?
    • Should do this stuff everywhere.
    • But if you have a switch case with 200 cases, you'll get 200 nots in the default, whether you cared or not. This PR avoids that unless you actually need the negation.
  • Let's come back to use-cases.
    • not Promise
    • Control flow issues.
  • With this PR, it is very nice that it's both faster and that you don't see the nots in hover unless you need.
  • We do want to experiment a bit more with decomposing existing primitives here. There are some unknown unknowns here.
    • Is object equivalent to {} & not number & not string & not boolean & not symbol?
    • Is {} equivalent to not null & not undefined?
    • Maybe Extract and Exclude can be defined in this way?
      • Or rather, have the compiler produce intersections of negations wherever we otherwise produce these.
        • Aside: added a specific mechanism for reducing intersections in the presence of negations.
  • What does negation mean in the presence of optional properties?
    • not { optionalProp?: Type }
    • Out of time!

Activity

  1. DanielRosenwasser commented on Sep 1, 2026

    @DanielRosenwasser
    MemberAuthor

    An optional property in { a?: string } is sort of like not { a: unknown } | { a: string }. If you trust that desugaring, I guess De Morgan's law says it's { a: unknown } & not { a: string }.

    In other words, a must be present, but it better not be a string.

  2. DanielRosenwasser commented on Sep 1, 2026

    @DanielRosenwasser
    MemberAuthor

    Something else that's to think about is that { a: string, b: number } is roughly { a: string } & { b: number }.

    So I'd expect not { a: string, b: number } to be consistent with not { a: string } | not { b: number }?

  3. DanielRosenwasser commented on Sep 1, 2026

    @DanielRosenwasser
    MemberAuthor

    We nerd-sniped Ryan Cavanaugh (@RyanCavanaugh) into talking about what the heck not void is.

  4. RyanCavanaugh commented on Sep 1, 2026

    @RyanCavanaugh
    Member

    Here's an AI summary of the discussion. The recording started a little bit late


    Negated Types in TypeScript — Design Discussion Summary

    The recording begins at the end of an explanation of the proposed subtype/assignability rules, so the discussion primarily covers use cases, control-flow integration, inference, compatibility, and implementation strategy rather than introducing the syntax from first principles.

    1. The proposed type constructor and its immediate uses

    The discussion opens by finishing a point about fresh object types. TypeScript normally treats object types as open: an object described as { x: number } may possess additional properties that are not mentioned in its static type. This makes it difficult to prove that an object is not assignable to some other structural type. Fresh object literals are different because excess-property checking gives the compiler a limited form of closed-world knowledge. When checking a fresh object literal, the compiler can sometimes conclude that the object definitely lacks the additional properties needed to satisfy another type. That information can participate in reasoning about a negated type.

    The conversation then moves from relationship rules to concrete uses. The proposed constructor can be understood schematically as not T, usually appearing in an intersection:

    S & not T

    This denotes the portion of S that cannot overlap with T. Unlike today's Exclude<S, T>, this is intended to be a primitive type-system operation rather than a distributive conditional over the presently visible union constituents of S.

    Several practical examples motivate the feature.

    Preventing accidentally ignored promises

    One use is a replacement for void in APIs or lint-oriented helpers that intentionally discard values. Existing rules can conflict:

    • One rule may require an explicitly marked discarded expression, such as void expression.
    • Another may prohibit discarding a promise without awaiting or otherwise handling it.

    A parameter accepting "anything except a promise" could express the intended policy directly:

    declare function discard<T extends not PromiseLike<unknown>>(value: T): void;

    The exact syntax was still conceptual, but the semantic goal was clear: ordinary return values can be ignored, while promise-like values produce an error. The same capability could improve standard-library declarations such as serialization APIs, where passing a promise is almost always a forgotten await.

    Excluding void, null, or undefined

    Negation also permits direct constraints such as:

    T extends not void
    T extends not null
    T extends not undefined

    A mapping operation could require a callback that produces a meaningful value rather than void. An API could reject null while continuing to admit undefined, or vice versa, without reconstructing the allowed universe as a union.

    Today, some of this is approximated through special types and special compiler behavior. A first-class negation operation would generalize the idea to values such as zero and the empty string:

    number & not 0
    string & not ""

    These are particularly useful for functions whose runtime preconditions require a nonzero divisor, a nonempty identifier, or another value that cannot be represented as a positive union of all valid possibilities.

    Negated template-literal patterns and property keys

    Template-literal types considerably strengthen the case for negation compared with earlier versions of the proposal. A negated template can describe strings that do not match a pattern. Combined with index signatures or mapped types, this can describe broad key spaces with explicit exceptions—for example, all keys beginning with a prefix except one reserved key.

    More generally, a key type such as:

    string & not keyof T

    can describe all string keys other than the keys already declared by T. Giving those remaining keys the value type never creates a form of pseudo-closed object type:

    type Closed<T> =
        T & { [K in string & not keyof T]: never };

    The precise formulation would depend on the final syntax and index-signature rules, but the important capability is exclusion from an infinite key domain. Existing template-literal relationship machinery reportedly handled much of this naturally, requiring little additional implementation.

    2. Selective control-flow production of negations

    The next part examines how negated types should interact with narrowing. An older implementation had replaced much of the compiler's type-fact machinery with negated types. Every failed test could add another negation to the flow type:

    Node
      & not Identifier
      & not FunctionDeclaration
      & not ClassDeclaration
      // ...

    After a long if chain or switch, dozens of such terms could accumulate. Although each negation makes the denoted value set smaller, it creates another compiler type object and more relationship work. Thus semantic refinement paradoxically increased the internal representation and computational cost at every branch.

    The current proposal is much more conservative. Control flow creates negated types only when all of the following roughly hold:

    1. The comparison is suitable for exact exclusion, such as comparison with a known literal.
    2. The relevant branch is one where a negation provides useful information.
    3. The use site has a contextual type containing a negation, indicating that this precision is actually required.

    For example:

    declare function divide(
        numerator: number,
        denominator: number & not 0
    ): number;
    
    declare const x: number;
    
    if (x !== 0) {
        divide(10, x);
    }

    The parameter type tells the checker that it needs to prove x is number & not 0. Control-flow analysis can then materialize that negation in the guarded branch.

    These flow-produced negations are treated as fresh and are removed at widening boundaries. A negation explicitly written in a declaration is durable; a negation manufactured solely to satisfy a contextual use can disappear when the value is assigned to an unconstrained local:

    if (x !== 0) {
        const y = x;
        divide(10, y); // May lose the `not 0` refinement.
    }

    The initializer of y has no negated contextual type, so the compiler may infer an ordinary number. This prevents negations from leaking through arbitrary code and appearing in inferred public APIs.

    The design was compared to other forms of TypeScript freshness, especially unique-symbol and literal freshness. It is not object-literal freshness in the excess-property-checking sense; rather, it marks an inferred refinement that may be discarded when its motivating context no longer exists.

    3. The central disagreement: pragmatism versus a coherent end state

    The temporary-variable example becomes the central point of contention. One side views the information loss as an expected consequence of TypeScript's existing contextual typing and widening rules. Similar behavior already occurs when a contextually typed function expression is extracted into a local declaration, or when a narrowed primitive literal passes through a widening binding. Under this view, selectively constructing negations is consistent with existing pragmatic compromises.

    The opposing concern is not that this particular inconsistency is unprecedented. It is that introducing a foundational operation without understanding its eventual role may add another permanent layer of ad hoc rules. Users will reasonably expect a harmless refactoring—introducing a temporary—to preserve a proven precondition. If a direct call accepts a value but the equivalent call through a local rejects it, many users will report the behavior as a bug.

    A related example involves inferred return types:

    function requireNonNull(x: unknown) {
        if (x === null) throw new Error();
        return x;
    }

    In a maximally precise system, the inferred return type might be:

    unknown & not null

    Likewise, a runtime truthiness assertion might return something approximating:

    unknown
      & not null
      & not undefined
      & not false
      & not 0
      & not 0n
      & not ""

    The conservative implementation can possess these facts internally but erase them at the return boundary unless a declaration or context explicitly requires them. That prevents novel types from appearing throughout existing programs, but it also gives up an important benefit: automatically inferring reusable predicates and assertion functions.

    The debate therefore concerns the scope of inference more than the value of the primitive itself. TypeScript's inference is one of its defining strengths. Requiring users to write every useful negated result explicitly could make the feature feel artificially constrained. Conversely, inferring maximal refinements everywhere would produce large, unfamiliar hover types and significant compatibility churn.

    No simple "always infer the most precise type" principle exists in today's language. Overloads are not inferred from implementation branches; return types often become unions; contextual predicates and generic narrowing are inferred only in selected positions. The language contains a decade of individually motivated rules that do not form a single uniform theory. Negated types expose those inconsistencies because they make previously inexpressible intermediate states representable.

    4. Whether the feature requires an opt-in mode

    The group repeatedly distinguishes two changes:

    1. Adding an explicitly written negated type constructor.
    2. Retrofitting negations into control flow, inference, utility types, and the standard library.

    The first is almost entirely additive. Existing programs contain no negated annotations, so the constructor alone should have little observable effect. The second category can change inferred types and reject code that previously compiled.

    One proposed direction is an opt-in mode representing the more principled world the language might have implemented had negation existed from the beginning. Such a mode could:

    • retain negated refinements through more bindings;
    • infer them in return types and predicates;
    • use them in the false branch of conditional types;
    • replace special nullish and type-fact behavior;
    • make extraction and exclusion symmetric;
    • strengthen standard-library signatures.

    The concern is that another mode would further complicate TypeScript's configuration story. Existing strictness settings already combine rules with different histories and motivations. Adding another "more correct" mode risks creating multiple dialects whose exact semantics are difficult to explain.

    The alternative is a pay-for-play model: negations become active only after user-written types introduce them. That minimizes compatibility risk and keeps ordinary hovers readable. Its weakness is that contextual demand becomes an invisible trigger. Two expressions that are operationally equivalent may receive different precision depending on whether a negated target type is visible at the immediate use site.

    5. Conditional types and negative constraints

    The conversation then turns to conditional types, which act as a form of type-level control flow:

    type Result<A, B> =
        A extends B
            ? TrueBranch<A>
            : FalseBranch<A>;

    In the true branch, TypeScript can record the positive constraint that A extends B. The tempting symmetric rule is to treat the false branch as if A extends not B.

    That rule is not universally sound. Failure of A extends B does not necessarily mean every inhabitant of A is outside B. A might partially overlap B, especially when unions, generic types, distributivity, and non-unit structural types are involved. Applying a straightforward Boolean complement or De Morgan transformation can therefore overstate what the failed conditional proves. The test is not always an "if and only if" partition of the type universe.

    This distinction matters for higher-order relationships. A negative constraint introduced in a false branch could cause another generic constraint to fail, potentially fixing apparent type holes but doing so on an unsound premise. The relationship machinery can safely use negation when it can prove that an intersection is empty:

    A & B = never

    That is stronger than merely knowing that A is not assignable to B.

    Despite this difficulty, conditional-type integration was not viewed as a reason to reject the primitive. It was treated as an important but separable design problem. Earlier implementations had experimented with false-branch negative constraints, and the amount of implementation code was not thought to be large. The challenge is identifying exactly where the inference is semantically justified.

    6. Structural objects and the meaning of "not an object type"

    Negation is clearest for primitive singleton values:

    number & not 0
    string & not ""

    It becomes subtler for structural objects because TypeScript object types are generally open. A value assignable to { x: number } may also have every property needed to be a promise. Therefore, knowing that something is an object—or even knowing a partial structural type—does not prove that it is not promise-like.

    This complicates types such as:

    object & not PromiseLike<unknown>

    At runtime, deciding whether an arbitrary object is promise-like is already heuristic: the usual test inspects a then property. The static type system faces the analogous open-world problem. Unless a type is closed, absence of a declared property is not necessarily evidence of actual absence.

    Fresh object literals provide one escape hatch, since excess-property knowledge makes them temporarily closed enough for certain proofs. Negated index signatures may provide another by expressing that all unlisted keys are never. The discussion suggests that negated types and closed-object modeling are closely related, but negation alone does not solve structural openness.

    A final edge case asks what it means to negate an object consisting entirely of optional properties:

    not { p?: string }

    Such a type is nearly useless as a refinement under open structural typing. Intersecting another object with { p?: string } usually just adds an optional property; it rarely produces never. If the positive type overlaps almost every object, its negation cannot conclusively filter much. This example underscores that the usefulness of not T depends on the compiler being able to prove disjointness, not merely on syntactically constructing a complement.

    7. Extract, Exclude, NonNullable, and existing type facts

    Negated types offer an appealing algebra for existing utility operations:

    Extract<T, U>  ≈ T & U
    Exclude<T, U>  ≈ T & not U

    Their union should ideally reconstruct the original type:

    (T & U) | (T & not U) ≈ T

    Today, Extract and Exclude are exposed as distributive conditional types, though internal compiler operations sometimes use intersections instead of constructing the utility alias. NonNullable<T> has also moved toward an intersection-based implementation. Negation could make these operations more symmetric at the compiler's foundational level even if the public aliases remained conditional types for compatibility.

    The same observation applies to {}. Under strict null checking, {} is commonly used to mean any non-nullish value, despite its misleading object-like spelling. Negation could represent that meaning directly:

    unknown & not null & not undefined

    A substantial amount of TypeScript's current type-fact machinery exists to reconstruct, decompose, and recombine these special domains. A mature negation primitive could replace some of those rules with ordinary intersections and simplification laws.

    However, rewriting existing library aliases or internal facts is precisely where compatibility risk appears. Even mathematically equivalent types can differ in display, inference, distributivity, alias preservation, or assignability edge cases. The discussion therefore favors measuring each prospective substitution independently rather than replacing everything at once.

    8. Readability, simplification, and performance

    Maximal precision is not automatically desirable in editor tooling. After dozens of checks, a technically exact hover containing dozens of intersections and negations is less useful than a recognizable named type. One participant explicitly favored preserving readable identifiers over exposing the full derivation of a control-flow result.

    The selective design addresses both readability and performance:

    • Negations are constructed only when demanded.
    • Fresh flow negations disappear at widening points.
    • Users who never write a negated type generally do not see one.
    • Long chains of irrelevant exclusions do not accumulate.
    • Relationship work is avoided unless the negation affects a check.

    The implementation nevertheless includes special reduction behavior. When an intersection contains a negated object type with discriminant properties, the checker can inspect those discriminants and filter incompatible constituents from a union. Ordinary intersections do not always eagerly perform this reduction. Negation supplies a strong signal that filtering is intentional, so the implementation performs additional work.

    This makes the feature more expressive but creates performance questions for constructions involving two very large unions. The compiler has extensive existing optimizations for unions, intersections, and discriminant reduction, but large-scale measurements remain necessary.

    9. Agreed direction and next experiments

    The meeting does not reach a final language design. It does reach broad agreement that the explicit type constructor now has considerably more independent value than it did in earlier proposals. Template-literal types, index signatures, non-promise constraints, non-void callbacks, nonzero values, nonempty strings, and pseudo-closed object shapes all provide useful applications even without pervasive inference.

    The unresolved question is how much of the existing type system should immediately be rebuilt around the new primitive.

    The proposed next step is empirical experimentation:

    1. Run the conservative implementation against a large corpus of popular TypeScript projects.
    2. Compare it with variants that retain or infer negations more aggressively.
    3. Test individual compatibility-sensitive changes separately, including conditional-type false branches, standard-library annotations, utility-type internals, and nullish type facts.
    4. Distinguish superficial baseline churn—such as changed hover text—from meaningful user-code failures.
    5. Examine real requests for negated types and patch representative library declarations to see how downstream programs behave.
    6. Use those results to determine whether a coherent default is possible or whether stronger semantics require an opt-in mode.

    The overall conclusion is cautiously favorable. Negated types appear powerful enough to justify continued work, but they are not merely another isolated type operator. Once present, they offer a more principled representation for several areas currently implemented through special cases. That creates pressure to unify control flow, utility operations, nullish handling, and structural-object reasoning around them. The design challenge is to obtain that coherence without making existing programs slower, less readable, or unexpectedly invalid—and without shipping an intentionally partial model that the language can never later revise.

  5. LukeAbby commented on Sep 2, 2026

    @LukeAbby

    What about the typeRelatedToDiscriminatedType special case? Basically this allows { type: "a" | "b" } to be assignable to { type: "a" } | { type: "b" }. I think this can cause a problem here.

    type A = { kind: "a" };
    type B = { kind: "b" };
    declare const ab: "a" | "b";
    
    const aOrB: A | B = { kind: ab }; // Ok
    const notAOrB: not (A | B)  = { kind: ab }; // Ok
  6. LukeAbby commented on Sep 2, 2026

    @LukeAbby

    Bonus issue:

    const _oops: not { a: 1 } = { a: 1 }; // Ok

    This one happens because the fresh literal { a: 1 } is allowed to widen to { a: number } but still hits the fresh literal path that ends up accepting this.

    Currently const _ok: not { a: 1 } = { a: 2 }; is essentially accepted by coincidence (it hits that same number widening). So something will have to prevent the widening. One route would be contextual typing. Though that raises some interesting questions like:

    const _f: not ((x: number) => string) = (arg) => doStuff(x)
    // should `arg` should contextually type as `not number`?
    // should the return should contextually type as `not string`?

    Currently there doesn't appear to be an attempt to contextually type this. Also it seems like it'll interact poorly with function overloading.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Design NotesNotes from our design meetings

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions