Did you notice that in all of the previous examples we never said the types of a Person's @name and @age? This is because the compiler inferred them for us.
When we wrote:
class Person
  getter name
  def initialize(@name)
    @age = 0
  end
end
john = Person.new "John"
john.name #=> "John"
john.name.size #=> 4
Since we invoked Person.new with a String argument, the compiler makes @name be a String too.
If we had invoked Person.new with another type, @name would have taken a different type:
one = Person.new 1
one.name #=> 1
one.name + 2 #=> 3
If you compile the previous programs with the tool hierarchy command, the compiler will show you a hierarchy graph with the types it inferred. In the first case:
- class Object
  |
  +- class Reference
     |
     +- class Person
            @name : String
            @age  : Int32
In the second case:
- class Object
  |
  +- class Reference
     |
     +- class Person
            @name : Int32
            @age  : Int32
What happens if we create two different people, one with a String and one with an Int32? Let's try it:
john = Person.new "John"
one = Person.new 1
Invoking the compiler with the tool hierarchy command we get:
- class Object
  |
  +- class Reference
     |
     +- class Person
            @name : (String | Int32)
            @age  : Int32
We can see that now @name has a type (String | Int32), which is read as a union of String and Int32. The compiler made @name have all types assigned to it.
In this case, the compiler will consider any usage of @name as always being either a String or an Int32 and will give a compile time error if a method is not found for both types:
john = Person.new "John"
one = Person.new 1
# Error: undefined method 'size' for Int32
john.name.size
# Error: no overload matches 'String#+' with types Int32
john.name + 3
The compiler will even give an error if you first use a variable assuming it has a type and later you change that type:
john = Person.new "John"
john.name.size
one = Person.new 1
Gives this compile-time error:
Error in foo.cr:14: instantiating 'Person:Class#new(Int32)'
one = Person.new 1
             ^~~
instantiating 'Person#initialize(Int32)'
in foo.cr:12: undefined method 'size' for Int32
john.name.size
          ^~~~~~
That is, the compiler does global type inference and tells you whenever you make a mistake in the usage of a class or method. You can go ahead and put a type restriction like def initialize(@name : String), but that makes the code a bit more verbose and also less generic: everything will work just fine if you create Person instance with types that have the same interface as a String, as long as you use a Person's name like if it were a String.
If you do want to have different Person types, one with @name being an Int32 and one with @name being a String, you must use generics.
If an instance variable is not assigned in all of the initialize defined in a class, it will be considered as also having the type Nil:
class Person
  getter name
  def initialize(@name)
    @age = 0
  end
  def address
    @address
  end
  def address=(@address)
  end
end
john = Person.new "John"
john.address = "Argentina"
The hierarchy graph now shows:
- class Object
  |
  +- class Reference
     |
     +- class Person
            @name : String
            @age : Int32
            @address : String?
You can see @address is String?, which is a short form notation of String | Nil. This means that the following gives a compile time error:
# Error: undefined method 'size' for Nil
john.address.size
To deal with Nil, and generally with union types, you have several options: use an if var, if var.is_a?, case and is_a?.
Instance variables can also be initialized outside initialize methods:
class Person
  @age = 0
  def initialize(@name)
  end
end
This will initialize @age to zero in every constructor. This is useful to avoid duplication, but also to avoid the Nil type when reopening a class and adding instance variables to it.
In certain cases you want to tell the compiler to fix the type of an instance variable. You can do this with ::
class Person
  @age : Int32
  def initialize(@name)
    @age = 0
  end
end
In this case, if we assign something that's not an Int32 to @age, a compile-time error will be issued at the assignment location.
Note that you still have to initialize the instance variables, either with a catch-all initializer or within an initialize method: there are no "default" values for types.