diff --git a/NEWS.md b/NEWS.md
index 6db22fa1f8be7..67f99d27536a4 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -78,6 +78,8 @@ Build system changes
 
 New library functions
 ---------------------
+* `@check cond [msg]` can be used to check if a condition holds and to error otherwise ([#41342])
+
 
 * `in!(x, s::AbstractSet)` will return whether `x` is in `s`, and insert `x` in `s` if not.
 * The new `Libc.mkfifo` function wraps the `mkfifo` C function on Unix platforms ([#34587]).
diff --git a/base/error.jl b/base/error.jl
index 37ceb39253e38..627e453b21bb2 100644
--- a/base/error.jl
+++ b/base/error.jl
@@ -237,6 +237,71 @@ macro assert(ex, msgs...)
     return :($(esc(ex)) ? $(nothing) : throw(AssertionError($msg)))
 end
 
+# Copied from `macro assert` above for bootstrapping reasons
+function prepare_error(ex, msgs...)
+    msg = isempty(msgs) ? ex : msgs[1]
+    if isa(msg, AbstractString)
+        msg = msg # pass-through
+    elseif !isempty(msgs) && (isa(msg, Expr) || isa(msg, Symbol))
+        # message is an expression needing evaluating
+        msg = :(Main.Base.string($(esc(msg))))
+    elseif isdefined(Main, :Base) && isdefined(Main.Base, :string) && applicable(Main.Base.string, msg)
+        msg = Main.Base.string(msg)
+    else
+        # string() might not be defined during bootstrap
+        msg = quote
+            msg = $(Expr(:quote,msg))
+            isdefined(Main, :Base) ? Main.Base.string(msg) :
+                (Core.println(msg); "Error during bootstrap. See stdout.")
+        end
+    end
+    return msg
+end
+
+"""
+    CheckError([msg])
+
+The checked condition did not evaluate to `true`.
+Optional argument `msg` is a descriptive error string.
+
+# Examples
+```jldoctest
+julia> @check false "this is not true"
+ERROR: CheckError: this is not true
+```
+
+`CheckError` is usually thrown from [`@check`](@ref).
+"""
+struct CheckError <: Exception
+    msg::AbstractString
+end
+CheckError() = CheckError("")
+
+"""
+    @check cond [text]
+
+Throw an [`CheckError`](@ref) if `cond` is `false`.
+Message `text` is optionally displayed upon check failure.
+
+Similar to [`@assert`](@ref), except `@check` is never disabled.
+
+# Examples
+```jldoctest
+julia> @check iseven(3) "3 is an odd number!"
+ERROR: CheckError: 3 is an odd number!
+
+julia> @check isodd(3) "What even are numbers?"
+```
+
+!!! compat "Julia 1.8"
+    This macro was added in Julia 1.8.
+
+"""
+macro check(ex, msgs...)
+    msg = prepare_error(ex, msgs...)
+    return :($(esc(ex)) ? $(nothing) : throw(CheckError($msg)))
+end
+
 struct ExponentialBackOff
     n::Int
     first_delay::Float64
diff --git a/base/errorshow.jl b/base/errorshow.jl
index 74afc191fcc4d..5f9d092191d67 100644
--- a/base/errorshow.jl
+++ b/base/errorshow.jl
@@ -157,6 +157,7 @@ showerror(io::IO, ex::InterruptException) = print(io, "InterruptException:")
 showerror(io::IO, ex::ArgumentError) = print(io, "ArgumentError: ", ex.msg)
 showerror(io::IO, ex::DimensionMismatch) = print(io, "DimensionMismatch: ", ex.msg)
 showerror(io::IO, ex::AssertionError) = print(io, "AssertionError: ", ex.msg)
+showerror(io::IO, ex::CheckError) = print(io, "CheckError: ", ex.msg)
 showerror(io::IO, ex::OverflowError) = print(io, "OverflowError: ", ex.msg)
 
 showerror(io::IO, ex::UndefKeywordError) =
diff --git a/base/exports.jl b/base/exports.jl
index 6b783eff9179b..286e8e3400646 100644
--- a/base/exports.jl
+++ b/base/exports.jl
@@ -128,6 +128,7 @@ export
 # Exceptions
     CanonicalIndexError,
     CapturedException,
+    CheckError,
     CompositeException,
     DimensionMismatch,
     EOFError,
@@ -1063,6 +1064,7 @@ export
     @polly,
 
     @assert,
+    @check,
     @atomic,
     @atomicswap,
     @atomicreplace,
diff --git a/doc/src/base/base.md b/doc/src/base/base.md
index 64c635ed3043b..1ee4bf0a55575 100644
--- a/doc/src/base/base.md
+++ b/doc/src/base/base.md
@@ -395,10 +395,12 @@ Base.backtrace
 Base.catch_backtrace
 Base.current_exceptions
 Base.@assert
+Base.@check
 Base.Experimental.register_error_hint
 Base.Experimental.show_error_hints
 Base.ArgumentError
 Base.AssertionError
+Base.CheckError
 Core.BoundsError
 Base.CompositeException
 Base.DimensionMismatch
diff --git a/test/misc.jl b/test/misc.jl
index e0961c538921a..0dd8e8f17df17 100644
--- a/test/misc.jl
+++ b/test/misc.jl
@@ -5,61 +5,77 @@ include("testhelpers/withlocales.jl")
 
 # Tests that do not really go anywhere else
 
-# test @assert macro
-@test_throws AssertionError (@assert 1 == 2)
-@test_throws AssertionError (@assert false)
-@test_throws AssertionError (@assert false "this is a test")
-@test_throws AssertionError (@assert false "this is a test" "another test")
-@test_throws AssertionError (@assert false :a)
-let
-    try
-        @assert 1 == 2
-        error("unexpected")
-    catch ex
-        @test isa(ex, AssertionError)
-        @test occursin("1 == 2", ex.msg)
-    end
-end
-# test @assert message
-let
-    try
-        @assert 1 == 2 "this is a test"
-        error("unexpected")
-    catch ex
-        @test isa(ex, AssertionError)
-        @test ex.msg == "this is a test"
-    end
-end
-# @assert only uses the first message string
-let
-    try
-        @assert 1 == 2 "this is a test" "this is another test"
-        error("unexpected")
-    catch ex
-        @test isa(ex, AssertionError)
-        @test ex.msg == "this is a test"
-    end
-end
-# @assert calls string() on second argument
-let
-    try
-        @assert 1 == 2 :random_object
-        error("unexpected")
-    catch ex
-        @test isa(ex, AssertionError)
-        @test !occursin("1 == 2", ex.msg)
-        @test occursin("random_object", ex.msg)
+# test @assert and `@check` macro
+# These have the same semantics (except `@assert` is allowed
+# to be disabled in the future), so we test them with the same tests.
+ASSERT_OR_CHECK = Ref(:assert)
+macro assert_or_check(expr...)
+    if ASSERT_OR_CHECK[] == :assert
+        var"@assert"(__source__, __module__, expr...)
+    else
+        var"@check"(__source__, __module__, expr...)
     end
 end
-# if the second argument is an expression, c
-let deepthought(x, y) = 42
-    try
-        @assert 1 == 2 string("the answer to the ultimate question: ",
-                              deepthought(6, 9))
-        error("unexpected")
-    catch ex
-        @test isa(ex, AssertionError)
-        @test ex.msg == "the answer to the ultimate question: 42"
+@testset "@$(val)" for val in (:assert, :check)
+    ASSERT_OR_CHECK[] = val
+    @eval begin # eval to delay macro expansion until after we've assigned `val`
+        E = ASSERT_OR_CHECK[] == :assert ? AssertionError : CheckError
+        @test_throws E (@assert_or_check 1 == 2)
+        @test_throws E (@assert_or_check false)
+        @test_throws E (@assert_or_check false "this is a test")
+        @test_throws E (@assert_or_check false "this is a test" "another test")
+        @test_throws E (@assert_or_check false :a)
+        let
+            try
+                @assert_or_check 1 == 2
+                error("unexpected")
+            catch ex
+                @test isa(ex, E)
+                @test occursin("1 == 2", ex.msg)
+            end
+        end
+        # test the macro's message
+        let
+            try
+                @assert_or_check 1 == 2 "this is a test"
+                error("unexpected")
+            catch ex
+                @test isa(ex, E)
+                @test ex.msg == "this is a test"
+            end
+        end
+        # the macro only uses the first message string
+        let
+            try
+                @assert_or_check 1 == 2 "this is a test" "this is another test"
+                error("unexpected")
+            catch ex
+                @test isa(ex, E)
+                @test ex.msg == "this is a test"
+            end
+        end
+        # the macro calls string() on second argument
+        let
+            try
+                @assert_or_check 1 == 2 :random_object
+                error("unexpected")
+            catch ex
+                @test isa(ex, E)
+                @test !occursin("1 == 2", ex.msg)
+                @test occursin("random_object", ex.msg)
+            end
+        end
+        # if the second argument is an expression, c
+        let deepthought(x, y) = 42
+            try
+                @assert_or_check 1 == 2 string("the answer to the ultimate question: ",
+                                    deepthought(6, 9))
+                error("unexpected")
+            catch ex
+                @test isa(ex, E)
+                @test ex.msg == "the answer to the ultimate question: 42"
+            end
+        end
     end
 end