Skip to content

Negated Types - #63926

Open
Wesley Wigham (weswigham) wants to merge 1 commit into
microsoft:mainfrom
weswigham:port-negated-types
Open

Negated Types#63926
Wesley Wigham (weswigham) wants to merge 1 commit into
microsoft:mainfrom
weswigham:port-negated-types

Conversation

@weswigham

@weswigham Wesley Wigham (weswigham) commented Aug 20, 2026

Copy link
Copy Markdown
Member

This PR is a port of microsoft/typescript-go#4200 which is a port of #29317 for this codebase, with further work to enable control flow creation of negations added on.

To repeat from those PRs:

Long have we spoken of them in hushed tones and referenced them in related issues, here they are:

Negated Types

Negated types, as the name may imply, are the negation of another type. Conceptually, this means that if string covers all values which are strings at runtime, a "not string" covers all values which are... not. We had hoped that conditional types would by and large subsume any use negated types would have... and they mostly do, except in many cases we need to apply the constraint implied by the conditional's check to it's result. In the true branch, we can just intersect the extends clause type, however in the false branch we've thus far been discarding the information. This means unions may not be filtered as they should (especially when conditionals nest) and information can be lost. So that ended up being the primary driver for this primitive - it's taking what a conditional type false branch implies, and allowing it to stand alone as a type.

Syntax

not T

where T is another type. I'm open to bikeshedding this, or even shipping without syntax available, but among alternatives (!, ~) not reads pretty well.

Identities

These are little tricks we do on negated type construction to help speed things along (and give negations on algebraic types canonical forms).

  • not not T is T
  • not (A | B | C | ...) is not A & not B & not C & not ...
  • not (A & B & C & ...) is not A | not B | not C | not ...
  • not unknown is never
  • not never is unknown
  • not any is any (since any is the NaN of types and behaves as both the bottom and top)
  • T | not T is unknown, T & not T is never

Assignability Rules

Negated types, for perhaps obvious reasons, cannot be related structurally - the only sane way to relate them is in a higher-order fashion. Thus, the rules governing these relations are very important.

  • A negated type not S is related to a negated type not T if T is related to S.
    This follows from the set membership inversion that a negation implies - if normally a type S and a type T would be related if S is a subset of T, when we take the complements of those sets, not S and not T, those sets share an inverse relationship to the originals.
  • A type S is related to a negated type not T if the intersection of S and T is empty
    We want to check if for all values in S, none of those values are also in T (since if they are, S is not in the negation of T). The intersection of S and T, when simplified and evaluated, is exactly the description of the common domain of the two. If this domain is empty (never), then we can conclude that there is no overlap between the two and that S must lie within not T.
  • A negated type not S is not related to a type T.
    A negated type describes a set of values that reaches from unknown to its bound, while a normal type describes values from its bound to never - it's impossible for a negated type to satisfy a normal type

Assignability Addendum for Fresh Object Types

Frequently we want to consider a fresh object type as a singleton type (indeed, some examples in the refs assume this) - it corresponds to one runtime value, not the bounds on a value (meaning, as a type, both its upper and lower bounds are itself). Using this, we can add one more rule that allows fresh literal types to easily satisfy negated object types.

  • A fresh object type S is related to a negated type not T if S is not related to T.
    Since S is a singleton type, we can assume that so long as it's type is not in T, then it is in not T.

Examples

Examples of negated type usage can be found in the tests of this PR (there's a few hundred lines of them, and probably some more to come for good measure), but here's some of the common ones, pulled from the referenced issues:

declare function ignore<T extends not (object & Promise<any>)>(value: T): void;
declare function readFileAsync(): Promise<string>;
declare function readFileSync(): string;
ignore(readFileSync());     // OK
ignore(readFileAsync());    // Should error

declare function map<T, U extends not void>(values: T[], map: (value: T) => U) : U[]; // validate map callback doesn't return void

function foo() {}

map([1, 2, 3], n => n + 1); // OK
map([1, 2, 3], foo);        // Should error

function asValid<T extends not null>(value: T, isValid: (value: T) => boolean) : T | null {
    return isValid(value) ? value : null;
}

declare const x: number;
declare const y: number | null;
asValid(x, n => n >= 0);    // OK
asValid(y, n => n >= 0);    // Should error

function tryAt<T extends not undefined>(values: T[], index: number): T | undefined {
    return values[index];
}

declare const a: number[];
declare const b: (number | undefined)[];
tryAt(a, 0);    // OK
tryAt(b, 0);    // Should error

Fixes #26240.
Allows #27711 to be cleanly fixed with a lib change (example in the tests).

Ref #4183, #4196, #7648, #12215, #18280

Further work:

  • API support for getNegatedType
  • Automatic introduction of negated types in further control flow narrowing constructs (assertions?)
  • Automatic creation of substitute-negated types in conditional type false branches (was in original PR but has gone missing)
  • Looser rules for comparability for intersections with negated types (current restrictiveness causes smoke test failures)

Copilot AI balanced review requested due to automatic review settings August 20, 2026 18:51
@github-project-automation github-project-automation Bot moved this to Not started in PR Backlog Aug 20, 2026
@typescript-automation typescript-automation Bot added Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug labels Aug 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds negated types, their syntax, type relations, inference, normalization, and control-flow narrowing.

Changes:

  • Adds not T parsing, AST/API encoding, and declaration emit.
  • Implements negated-type construction, inference, assignability, and CFA.
  • Adds extensive compiler tests and updated baselines.

Several regressions remain, including incorrect diagnostics, lost literal narrowing, leaked CFA types, and the still-failing #26240 case.

Reviewed changes

Copilot reviewed 255 out of 260 changed files in this pull request and generated 6 comments.

Show a summary per file
File group Description
tsc/internal/{scanner,parser,ast,api/encoder} Adds not syntax and AST serialization.
tsc/internal/checker/* Implements negated types, relations, inference, normalization, and narrowing.
packages/typescript/src/{ast,api,enums}/* Exposes generated AST and flag changes.
tools/scripts/tsc/ast.json, Herebyfile.mjs Updates code generation definitions.
tsc/testdata/tests/cases/conformance/types/negated/* Adds negated-type conformance coverage.
tsc/testdata/baselines/reference/{compiler,conformance}/* Updates expected diagnostics, types, symbols, emit, and declarations.
Files not reviewed (5)
  • tsc/internal/api/encoder/decoder_generated.go: Generated file
  • tsc/internal/api/encoder/encoder_generated.go: Generated file
  • tsc/internal/ast/ast_generated.go: Generated file
  • tsc/internal/ast/kind_generated.go: Generated file
  • tsc/internal/ast/kind_stringer_generated.go: Generated file

Comment on lines +25671 to +25675
// Fresh negated types introduced by control flow narrowing are widened away (dropped) when a
// narrowed value escapes into a location that does not itself want a negation, so that 'not X'
// does not leak into an inferred declaration. When the contextual type does mention a negation
// (e.g. a 'not string' parameter or property), the fresh negation is preserved.
return c.getRegularTypeOfLiteralType(c.removeOrRegularizeNegatedTypes(t, containsFreshNegatedType(t) && !containsNegatedType(contextualType)))
Comment on lines +636 to +637
if introduceNegation && !doubleEquals {
return c.introduceNegationIntoNarrowedType(filtered, valueType)

type OnlyNumber<T extends number> = T;
type ToNumber<T extends number | string> =
T extends string ? undefined : OnlyNumber<T>;
@@ -0,0 +1,27 @@
arrayDestructuringInSwitch1.ts(19,12): error TS2367: This comparison appears to be unintentional because the types '("false" | "true") & not any[]' and 'string' have no overlap.
Comment on lines +223 to +227
for _, nonNegatedType := range nonNegatedSet {
if c.isTypeSubtypeOf(nonNegatedType, negatedBounds) {
return true
}
}
Comment on lines +12 to +13
// not (A & B) stays as a single negation of the intersection.
type NotAandB = not (A & B);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug

Projects

Status: Not started

Development

Successfully merging this pull request may close these issues.

Conditional type doesn't narrow primitive types

2 participants