lloyAlloy on GitHub

← Every RFC

Type negation with ~

SyntaxImplementedThe file on GitHub →

Summary

A type may be negated. ~number is every value that is not a number, and it reads in a binding, a parameter, a return, a union, and a generic bound. The compiler lowers it to a Luau type function, so the analyzer checks it and the editor already prints it.

Motivation

Luau's own solver has negation. It builds one whenever a test removes a type, and it prints it. Take an unknown that a test narrows:

local function f(x: unknown)
    if type(x) ~= "number" then
        local y: number = x
    end
end

Luau answers:

TypeError: Expected this to be 'number', but got '~number';
`~number` cannot be `number`

So a reader already meets ~number in a hover and in a diagnostic. The reader cannot write it. The type exists in the checker and has no spelling in the source, which is the whole gap this closes.

The shapes that want it today:

Design

The form

~ before a type negates it:

local t: ~number = "text"

local function label(v: ~nil): string
    return tostring(v)
end

type NotText = ~string

~ binds tighter than | and &, so ~number | string is (~number) | string. Parentheses read the other grouping: ~(number | string).

~ takes one type operand. A second ~ is an error rather than a double negation, because ~~number says number the long way:

error(3.x): TypeError: `~~number` negates twice; write `number`

Why ~ and not not

Three reasons, in order of weight.

not number stays available as an alternative in the section below, if the tilde proves hard to read.

Emit

Luau has no negation syntax. Its parser rejects ~number:

SyntaxError: Expected type, got '~'

It does have the constructor, in the type function library. A file that writes a negation declares one type function on its first line, beside the runtime require:

type function __neg(t) return types.negationof(t) end

and ~T lowers to __neg<T>. The function sits in the file, not in the std. On luau-lsp 1.69.0 a type function exported from another module reaches the importer unchecked, so L.neg<number> accepted 5. The same function declared in the file enforces:

--!strict
type function __neg(t) return types.negationof(t) end
local a: __neg<number> = "text"   -- accepted
local b: __neg<number> = 5        -- rejected
TypeError: Expected this to be '~number', but got 'number'

The compiler reports that last case in its own words: "number does not fit: the type asks for ~number, so it takes anything but number".

Two facts that make this work. The analyzer reduces the type function and then prints the result as ~number, so a diagnostic names the type the author wrote, not the lowering. And enforcement needs strict mode, which every Alloy project has: alloy init writes .config.luau with languagemode = "strict".

The emit stays on the source's own line, because ~T and __alloy.neg<T> both sit inside one type annotation.

Where it applies

Every type position: a binding, a parameter, a return, a field, a type alias, a union member, an intersection member, a generic argument, and a generic bound (<T: ~nil>).

types.negationof negates a primitive, a singleton, a class, and a union of them. It fails on a table type and on a function type, so the compiler reports those operands before the analyzer runs: a table literal, an array, a std table type, a struct, and a function type. An alias of a record reaches the analyzer, whose report the compiler rewrites to the same words.

Two positions reject it, each with its own report:

The language server

Drawbacks

Alternatives

Prior Art

Unresolved questions