Skip to content

Instantly share code, notes, and snippets.

@nobodywasishere
Last active May 16, 2025 17:13
Show Gist options
  • Save nobodywasishere/6c1d42eda30d469346b21516b6294710 to your computer and use it in GitHub Desktop.
Save nobodywasishere/6c1d42eda30d469346b21516b6294710 to your computer and use it in GitHub Desktop.
Type safe equality checker for Crystal lang (1.14.0)
#
# These are a series of monkey-patches designed to make type-unsafe
# equality comparisons (via `==` and `===`) more apparent via warnings, enabled
# by the `-Dtype_safe_equality_check` flag. Typically, you are allowed to compare
# anything to anything in Crystal, which can lead to cases like:
#
# ```crystal
# some_string[idx]? == "j"
# ```
#
# Where everything compiles and runs fine, but this equality always evaluates to false
# as `String#[]?` returns `Char?`. This is a serious footgun with Crystal that I have
# run into a lot while developing, and has lead to several hidden bugs in my code.
#
# This implements a stricter equality, where deprecation warnings are given when
# comparing objects of different types, ignoring unions that only include a type and `Nil`.
#
# For example, this is valid:
#
# ```crystal
# def receive(msg : String?)
# msg == "hello" || msg == nil
# end
# ```
#
# And this is not:
#
# ```crystal
# def receive(msg : String?)
# msg == :hello
# # ^^ warning: This equality is type-unsafe and may always evaluate to false. \
# Use `#type_unsafe_equals?` if this is intentional.
# end
# ```
#
# As a fallback, the method `#type_unsafe_equals?(other)` can be used to bypass this restriction.
# Unfortunately, there is not such a fallback for `case` statements at this time, so
# these may give warnings that are more difficult to work around.
#
# This only has an impact on code logic when the flag `-Dtype_safe_equality_check` is enabled, and it's
# recommend to only enable it for checks, not normal compilation.
#
# When using this, it is recommended to ignore all warnings coming from this file, stdlib, and the `lib/` folder.
# Codegen is also not required. On Linux / MacOS in a Makefile, this can be accomplished with the command:
# ```sh
# type_safe_equality_check:
# crystal build path/to/main/file.cr \
# -Dtype_safe_equality_check \
# --no-codegen --no-debug \
# --error-on-warnings \
# --exclude-warnings=lib \
# --exclude-warnings=path/to/type_safe_equality.cr \
# --exclude-warnings="$(shell crystal env CRYSTAL_PATH | cut -d':' -f2)"
# ```
#
private macro type_safe_eq
{% if flag?(:type_safe_equality_check) %}
def ==(other : self?)
return false if other.nil?
self == other
end
def ==(other : Nil)
false
end
@[Deprecated("This equality is type-unsafe and may always evaluate to false. Use `#type_unsafe_equals?` if this is intentional.")]
def ==(other)
false
end
{% end %}
# NOTE(margret): Small helper method to swallow the deprecation warning
def type_unsafe_equals?(other) : Bool
self == other
end
end
struct Value
type_safe_eq
end
struct ReferenceStorage(T)
type_safe_eq
end
class Log::Metadata
type_safe_eq
end
abstract struct Enum
type_safe_eq
end
class Array(T)
type_safe_eq
end
struct StaticArray(T, N)
type_safe_eq
end
struct Complex
type_safe_eq
end
class Reference
type_safe_eq
end
struct Tuple
type_safe_eq
end
{% if flag?(:type_safe_equality_check) %}
struct Struct
def ==(other : self | Nil) : Bool
if other.is_a?(self)
{% for ivar in @type.instance_vars %}
# NOTE(margret): These could potentially be type-unsafe, but hard to debug
# them given the warning is added to here instead of the caller of `==`.
# These are swallowed by ignoring this file (for now)
return false unless @{{ivar.id}} == other.@{{ivar.id}}
{% end %}
end
!other.nil?
end
def ==(other : Nil)
false
end
@[Deprecated("This equality is type-unsafe and may always evaluate to false. Use `#type_unsafe_equals?` if this is intentional.")]
def ==(other)
false
end
end
class Object
def ===(other : self?)
return false if other.nil?
self == other
end
def ===(other : Nil)
false
end
@[Deprecated("This equality is type-unsafe and may always evaluate to false")]
def ===(other)
false
end
end
struct Nil
def ===(other)
false
end
def ==(other)
false
end
end
class Regex
# NOTE(margret): Monkey-patches to allow nillable comparisons,
# otherwise will fall back on `Object#==(other)`
def ===(other : String?) : Bool
return false if other.nil?
self === other
end
end
class String
# NOTE(margret): Monkey-patches to allow nillable comparisons,
# otherwise will fall back on `Object#==(other)`
def ===(other : String?) : Bool
return false if other.nil?
self === other
end
end
{% end %}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment