Type Annotation Basics
Variable Types
# Plain Ruby β no annotations needed
name = "Alice"
count = 0
# RBS Inline does not annotate local variables β
# types live on method signatures and attributes.
# (Sorbet has T.let() for locals; RBS does not.)
puts name
puts count // Explicit annotations
let firstName: string = "Alice";
let score: number = 0;
// Type inference β these are equivalent:
let greeting = "Hello, world!"; // inferred: string
let itemCount = 0; // inferred: number
const MAX = 100; // inferred: 100 (literal type)
console.log(firstName, score);
console.log(greeting, itemCount, MAX); TypeScript infers types from initial values β you rarely need to annotate local variables. Always annotate function parameters and return types; inference is unreliable there.
const literals get a narrowed type (100, not just number). RBS Inline does not annotate local variables at all β types live on method signatures and attr_reader declarations.Function Types
# Plain Ruby
def greet(name)
"Hello, #{name}!"
end
# With RBS Inline β #: comment immediately before def
#: (String name) -> String
def greet(name)
"Hello, #{name}!"
end
puts greet("Alice") // Named function
function greet(name: string): string {
return `Hello, ${name}!`;
}
// Arrow function
const greetArrow = (name: string): string =>
`Hello, ${name}!`;
// Callback type annotation
function transform(
values: number[],
fn: (n: number) => number,
): number[] {
return values.map(fn);
}
console.log(greet("Alice"));
console.log(greetArrow("Bob"));
console.log(transform([1, 2, 3], n => n * 2)); Parameter types are required in strict mode. The return type is optional (inferred) but recommended for public APIs. Callback types are written
(param: Type) => ReturnType inline. RBS Inline's #: (Type param) -> ReturnType placed immediately before def is the closest structural match.Primitive Types
The Type Universe
puts 42.class # => Integer
puts 3.14.class # => Float
puts "hi".class # => String
puts true.class # => TrueClass
puts nil.class # => NilClass
# a type with no TypeScript equivalent
puts :name.class # => Symbol let age: number = 42; // both integers and floats are
let pi: number = 3.14; // of type numberβnot independent types
let name: string = "Bob";
let active: boolean = true;
let empty: null = null; // intentional absence
let missing: undefined = undefined; // uninitialised
// Types that have no Ruby equivalent:
let safe: unknown = "untrusted data"; // must narrow before use
function logHello(): void { console.log("hi"); } // ignores return value
function fail(): never { throw new Error("unreachable"); }
console.log(age, pi, name, active);
console.log(empty, missing, safe);
logHello(); any disables type checking entirely β avoid it. Prefer unknown for untrusted input; it forces you to narrow before use. never appears in functions that always throw and in exhaustive switch defaults. Plain Ruby methods always return a value (even if nil). RBS provides both void ("caller should not use this return value") and bot (the bottom type, equivalent to never).null and undefined
# Ruby: one "nothing" value
x = nil
puts x.nil? # => true
puts x&.upcase # => nil (safe navigation)
puts x || "default" # => "default" // With strictNullChecks: true (always use this)
let name2: string | null = null; // OK β explicit union
let name3: string | undefined; // OK β not yet assigned
// Optional chaining β equivalent to Ruby's &.
const user = { address: { city: "Portland" } };
const city = user?.address?.city;
const noCity = (null as any)?.address?.city;
// Nullish coalescing β like Ruby's || but ONLY for null/undefined
// (0 and "" do NOT trigger ??, but they DO trigger ||)
const display = name2 ?? "Anonymous";
const keepZero = 0 ?? "default"; // stays 0
console.log(name2, name3);
console.log(city, noCity);
console.log(display);
console.log("?? keeps 0:", keepZero, "|| gives:", 0 || "default"); With
strictNullChecks, null and undefined are distinct types that must be explicitly included in unions. RBS's String? (shorthand for String | nil) maps to TypeScript's string | null. ?? differs from || β it only triggers on null/undefined, not on 0 or "", which is usually what you want.Interfaces & Type Aliases
interface
# Ruby: pure duck typing β no declaration needed
module Quackable
def quack = raise NotImplementedError
def walk = raise NotImplementedError
end
class Duck
include Quackable
def quack = "Quack!"
def walk = "Waddle"
end
def make_it_quack(duck) = duck.quack
puts make_it_quack(Duck.new) interface Quackable {
quack(): string;
walk(): string;
name?: string; // optional property
}
function makeItQuack(duck: Quackable): string {
return duck.quack();
}
// No `implements` needed β structural typing checks shape
const duck = { quack: () => "Quack!", walk: () => "Waddle" };
const goose = {
quack: () => "Honk!",
walk: () => "Strut",
name: "Gerald",
};
console.log(makeItQuack(duck));
console.log(makeItQuack(goose)); TypeScript uses structural typing β an object satisfies an interface if it has the required shape, regardless of whether it declares
implements. This is the typed-language equivalent of Ruby's duck typing. implements is optional documentation, not a requirement.type Aliases
# No direct equivalent β Ruby uses modules,
# constants, or duck typing
UserId = Integer # constant alias (convention only)
user_id = 42
puts user_id type UserId = number;
type Name = string;
type Point = { x: number; y: number };
// Can express things interfaces can't:
type StringOrNumber = string | number;
type Nullable = T | null;
type Callback = () => void;
type Pair = [A, B];
const userId: UserId = 42;
const origin: Point = { x: 0, y: 0 };
const value: StringOrNumber = "hello";
const pair: Pair = ["Alice", 30];
console.log(userId, origin, value, pair); interface and type are largely interchangeable for object shapes. Convention: use interface for object shapes that may be extended or augmented (supports declaration merging); use type for unions, intersections, primitives, tuples, and computed types.Extending & Merging
module Named; end
module Aged; end
module Person
include Named
include Aged
end
class Employee
include Person
def initialize(name, age)
@name = name
@age = age
end
def introduce = "#{@name}, age #{@age}"
end
puts Employee.new("Alice", 30).introduce interface Named { name: string; }
interface Aged { age: number; }
// Interface extension
interface Person extends Named, Aged {
introduce(): string;
}
// Type intersection (equivalent for type aliases)
type PersonType = Named & Aged & { introduce(): string };
const person: Person = {
name: "Alice",
age: 30,
introduce() { return `${this.name}, age ${this.age}`; },
};
console.log(person.introduce());
console.log(person.name, person.age); Interface declaration merging β redeclaring the same interface name twice merges them β is unique to
interface and is the standard way to add properties to third-party library types (e.g. adding currentUser to Express's Request). Type aliases cannot be merged. & intersection and extends produce identical results for simple cases.Union & Intersection Types
Union Types & Discriminated Unions
# Plain Ruby: any type, no restriction
#: (String | Integer id) -> String
def find_user(id)
id.to_s
end
# Discriminated union via pattern matching (Ruby 3+)
shape = { kind: "circle", radius: 5.0 }
area = case shape
in { kind: "circle", radius: } then Math::PI * radius ** 2
in { kind: "rectangle", width:, height: } then width * height
end
puts find_user(42)
puts area.round(2) function findUser(id: string | number): string {
return id.toString();
}
// Discriminated union β each variant has a literal-typed "kind" tag
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "rectangle": return shape.width * shape.height;
case "triangle": return 0.5 * shape.base * shape.height;
}
}
console.log(findUser(42));
console.log(findUser("user-123"));
console.log(area({ kind: "circle", radius: 5 }).toFixed(2));
console.log(area({ kind: "rectangle", width: 4, height: 6 }));
console.log(area({ kind: "triangle", base: 3, height: 8 })); Discriminated unions (a union of objects sharing a common literal-typed "tag" field like
kind) are the primary pattern for modelling variant data in TypeScript. After checking shape.kind, the compiler narrows to the exact variant β equivalent to Ruby 3's case/in pattern matching but verified at compile time.Optional Properties & Parameters
# Keyword argument with default
def greet(name:, title: nil)
title ? "#{title} #{name}" : name
end
# RBS Inline β ? suffix means nilable (String | nil)
#: (name: String, ?title: String?) -> String
def greet(name:, title: nil); end
puts greet(name: "Alice")
puts greet(name: "Smith", title: "Dr.") interface Config {
host: string;
port?: number; // optional β type is number | undefined
tls?: boolean;
}
function greet(name: string, title?: string): string {
return title ? `${title} ${name}` : name;
}
// Default parameter: handles missing case AND narrows type inside body
function connect(host: string, port: number = 8080): void {
console.log(`Connecting to ${host}:${port}`);
}
const config: Config = { host: "example.com" };
console.log(config.host, config.port); // port is undefined
console.log(greet("Alice"));
console.log(greet("Smith", "Dr."));
connect("example.com");
connect("example.com", 443); ? on a TypeScript property or parameter means the value may be undefined (not null). RBS's Type? (nilable) maps most closely to Type | null in TypeScript β Ruby has one nothing-value (nil) where TypeScript has two (null and undefined). Default values both handle the missing case and narrow the type inside the function body.Arrays & Tuples
Arrays
names = ["Alice", "Bob"]
names.push("Carol")
puts names.inspect
# RBS Inline
#: () -> Array[String]
def names = ["Alice", "Bob"] const names1: string[] = ["Alice", "Bob"]; // preferred syntax
const names2: Array = ["Alice", "Bob"]; // generic syntax β identical
names1.push("Carol");
// names1.push(42); // Error! Would be caught at compile time
// Readonly β prevents mutation (enforced at compile time)
const frozen: readonly string[] = ["Alice", "Bob"];
// frozen.push("Carol"); // Error! Property 'push' does not exist
console.log(names1);
console.log(names2);
console.log(frozen); string[] and Array<string> are identical. Use readonly string[] (or ReadonlyArray<string>) for function parameters you don't intend to modify β it's the equivalent of Ruby's freeze for arrays, checked at compile time.Tuples
# Ruby: arrays used for fixed-shape multi-value returns
point = [1.0, 2.0]
x, y = point
def divmod_pair(a, b) = [a / b, a % b]
quotient, remainder = divmod_pair(17, 5)
puts "#{x}, #{y}"
puts "#{quotient}, #{remainder}" // Tuple β fixed length, each position has a known type
let point: [number, number] = [1.0, 2.0];
const [x, y] = point;
// Named tuple elements (TypeScript 4.0+)
type RGB = [red: number, green: number, blue: number];
// Multiple return values β typed equivalent of Ruby's multi-return
function divmod(a: number, b: number): [number, number] {
return [Math.floor(a / b), a % b];
}
const [quotient, remainder] = divmod(17, 5);
console.log(point, x, y);
console.log(quotient, remainder);
const coral: RGB = [255, 127, 80];
console.log(coral); Tuples enforce both the length and the type of each position β unlike Ruby arrays where
[1, "hello"] is perfectly legal. They're the idiomatic TypeScript way to return multiple typed values from a function. Named elements improve readability without any runtime cost.Generics
Generic Functions
# Plain Ruby: duck typing handles this automatically
def first(array)
array[0]
end
# RBS Inline β [T] declares the type variable
#: [T] (Array[T] array) -> T?
def first(array) = array[0]
puts first([1, 2, 3]).inspect
puts first(["a", "b"]).inspect
puts first([]).inspect // T is inferred from the argument β rarely needs to be explicit
function first(array: T[]): T | undefined {
return array[0];
}
// Multiple type parameters
function zip(a: A[], b: B[]): [A, B][] {
return a.map((item, index) => [item, b[index]]);
}
console.log(first([1, 2, 3])); // number | undefined
console.log(first(["a", "b"])); // string | undefined
console.log(first([])); // undefined
console.log(JSON.stringify(zip([1, 2], ["a", "b"]))); TypeScript infers generic type parameters from arguments β you rarely write
first<number>([1,2,3]). RBS Inline's [T] syntax is remarkably close to TypeScript's <T> β both declare the type variable before the parameter list. Ruby's duck typing gives you this for free with no syntax at all, but also no compile-time safety.Generic Constraints
# RBS Inline β T
def max(a, b) = a > b ? a : b
puts max(3, 7)
puts max("apple", "mango") // extends in generics means "constrained to", not "inherits from"
function longest(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
// keyof β safe dynamic property access
function getProperty(obj: T, key: K): T[K] {
return obj[key];
}
console.log(longest("hello", "hi")); // "hello"
console.log(longest([1, 2, 3], [1, 2])); // [1,2,3]
// longest(10, 20); // Error! number has no .length
const person = { name: "Alice", age: 30 };
console.log(getProperty(person, "name")); // "Alice"
console.log(getProperty(person, "age")); // 30
// getProperty(person, "missing"); // Error! extends in a generic constraint means "must have at least this shape". keyof T produces a union of the string keys of type T β essential for type-safe dynamic property access. RBS Inline's [T < ::Comparable[T]] upper-bound syntax is the direct parallel to TypeScript's <T extends Bound>.Generic Classes & Interfaces
# @rbs generic T -- declares T as the class type parameter
class Stack
#: () -> void
def initialize = @items = [] #: Array[T]
#: (T item) -> void
def push(item) = @items.push(item)
#: () -> T?
def pop = @items.pop
#: () -> T?
def peek = @items.last
#: () -> bool
def empty? = @items.empty?
end
stack = Stack.new
stack.push("hello")
stack.push("world")
puts stack.peek
puts stack.pop
puts stack.empty? interface Stack {
push(item: T): void;
pop(): T | undefined;
peek(): T | undefined;
isEmpty(): boolean;
}
class ArrayStack implements Stack {
private items: T[] = [];
push(item: T): void { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
peek(): T | undefined { return this.items.at(-1); }
isEmpty(): boolean { return this.items.length === 0; }
}
const stack = new ArrayStack();
stack.push("hello");
stack.push("world");
console.log(stack.peek()); // "world"
console.log(stack.pop()); // "world"
console.log(stack.peek()); // "hello"
console.log(stack.isEmpty()); // false Generic classes parameterise the type held by the data structure.
implements Stack<T> is optional β TypeScript would accept the class via structural typing anyway β but it serves as documentation and produces a clearer error if a method is missing.Classes
Typed Properties & Parameter Shorthand
class Person
attr_reader :name #: String
attr_reader :age #: Integer
#: (String name, Integer age) -> void
def initialize(name, age)
@name = name
@age = age
end
end
person = Person.new("Alice", 30)
puts person.name
puts person.age // Verbose form β declare then assign in constructor
class PersonVerbose {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
// Parameter properties β declare and assign in one step (preferred)
class Person {
constructor(
public readonly name: string,
private age: number,
) {}
introduce(): string { return `I'm ${this.name}, age ${this.age}`; }
}
const verbose = new PersonVerbose("Alice", 30);
console.log(verbose.name, verbose.age);
const person = new Person("Bob", 25);
console.log(person.name);
console.log(person.introduce());
// person.name = "Charlie"; // Error! name is readonly Constructor parameter properties (
public/private/protected/readonly in the constructor signature) both declare the field and assign the argument β equivalent to Ruby's attr_reader + initializer assignment combined. readonly prevents reassignment after construction.Access Modifiers
class BankAccount
def initialize(balance) = @balance = balance
def deposit(amount) = @balance += validate(amount)
def balance = @balance
private
def validate(amount)
raise ArgumentError, "must be positive" unless amount > 0
amount
end
end
account = BankAccount.new(100)
account.deposit(50)
puts account.balance class BankAccount {
private balance: number; // compile-time only β erased at runtime
#ledger: number[]; // JavaScript private field β enforced at runtime
constructor(balance: number) {
this.balance = balance;
this.#ledger = [balance];
}
deposit(amount: number): void {
if (amount <= 0) throw new Error("Amount must be positive");
this.balance += amount;
this.#ledger.push(this.balance);
}
getBalance(): number { return this.balance; }
getHistory(): number[] { return [...this.#ledger]; }
}
const account = new BankAccount(100);
account.deposit(50);
console.log("Balance:", account.getBalance());
console.log("History:", account.getHistory()); TypeScript's
private keyword is compile-time only β it's erased in the JavaScript output and accessible at runtime via obj["balance"]. Use JavaScript's #field syntax for true runtime privacy. Ruby's private is runtime-enforced. protected allows access in subclasses.Abstract Classes
class Shape
def area
raise NotImplementedError, "#{self.class}#area not implemented"
end
end
class Circle < Shape
def initialize(radius) = @radius = radius
def area = Math::PI * @radius ** 2
end
puts Circle.new(5).area.round(2) abstract class Shape {
abstract area(): number; // subclass must implement
abstract perimeter(): number;
// Concrete method shared by all subclasses:
describe(): string {
return `Area: ${this.area().toFixed(2)}, ` +
`Perimeter: ${this.perimeter().toFixed(2)}`;
}
}
class Circle extends Shape {
constructor(private radius: number) { super(); }
area(): number { return Math.PI * this.radius ** 2; }
perimeter(): number { return 2 * Math.PI * this.radius; }
}
class Rectangle extends Shape {
constructor(private width: number, private height: number) { super(); }
area(): number { return this.width * this.height; }
perimeter(): number { return 2 * (this.width + this.height); }
}
console.log(new Circle(5).describe());
console.log(new Rectangle(4, 6).describe());
// new Shape(); // Error! Cannot create instance of abstract class abstract enforces that subclasses implement specific methods at compile time β the TypeScript equivalent of Ruby's raise NotImplementedError convention, but the compiler catches the omission before the code ever runs.Enums
Enums
# Idiomatic Ruby: frozen symbol constants
module Direction
NORTH = :north
SOUTH = :south
EAST = :east
WEST = :west
end
puts Direction::NORTH
puts Direction.constants.inspect // String enum β values are readable in logs and JSON
enum Direction {
North = "NORTH",
South = "SOUTH",
East = "EAST",
West = "WEST",
}
// Numeric enum β creates a REVERSE mapping (often surprising)
enum Cardinal { North, South, East, West } // 0, 1, 2, 3
// Often cleaner: plain union type (no runtime overhead)
type DirectionUnion = "NORTH" | "SOUTH" | "EAST" | "WEST";
// Or const object + derived type (iterable AND type-safe)
const DIRECTION = {
North: "NORTH", South: "SOUTH", East: "EAST", West: "WEST",
} as const;
type DirectionValue = typeof DIRECTION[keyof typeof DIRECTION];
console.log(Direction.North); // "NORTH"
console.log(Cardinal.North); // 0
console.log(Cardinal[0]); // "North" β reverse lookup!
console.log(DIRECTION.North); // "NORTH" For most cases prefer a union type (
type Direction = "NORTH" | ...) over an enum β zero runtime cost, and values are plain strings. Numeric enums generate a bidirectional runtime object with reverse mappings β often confusing and the source of subtle bugs. Use const object + derived typeof when you need to iterate over values programmatically.Type Guards & Narrowing
typeof and instanceof
def process(value)
case value
when String then value.upcase
when Integer then value * 2
end
end
puts process("hello")
puts process(21) function process(value: string | number): string | number {
if (typeof value === "string") {
return value.toUpperCase(); // value is narrowed to string here
} else {
return value * 2; // value is narrowed to number here
}
}
class Circle { constructor(public radius: number) {} }
class Rectangle { constructor(public width: number, public height: number) {} }
function describe(shape: Circle | Rectangle): string {
if (shape instanceof Circle) {
return `Circle r=${shape.radius}`;
}
return `Rect ${(shape as Rectangle).width}Γ${(shape as Rectangle).height}`;
}
console.log(process("hello"));
console.log(process(21));
console.log(describe(new Circle(5)));
console.log(describe(new Rectangle(3, 4))); After a type guard check, TypeScript narrows the type for the rest of that branch β you get full access to the specific type's properties and methods without casting. This is the compile-time equivalent of Ruby's
case/when with class checks.Custom Type Predicates
def string?(value) = value.is_a?(String)
def user?(value)
value.respond_to?(:name) && value.respond_to?(:age)
end
puts string?("hello")
puts user?(Object.new) interface User { name: string; age: number; }
// Return type "value is Type" tells the compiler how to narrow
function isString(value: unknown): value is string {
return typeof value === "string";
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" && value !== null &&
"name" in value && "age" in value
);
}
// After calling the guard, TypeScript narrows the type:
const raw = '{"name":"Alice","age":30}';
const data: unknown = JSON.parse(raw);
if (isUser(data)) {
console.log("Valid user:", data.name, data.age);
}
const badData: unknown = JSON.parse('"just a string"');
console.log("Is string:", isString(badData));
console.log("Is user:", isUser(badData)); Type predicates connect runtime checks to compile-time types. They're essential for safely working with
unknown data from APIs, JSON responses, and external inputs. Without them the compiler can't know what you've verified at runtime.Exhaustiveness Checking
def process_result(value) = "Processed: #{value}"
def log_error(message) = "Error: #{message}"
[
{ type: "ok", value: 42 },
{ type: "error", message: "not found" },
].each do |result|
output = case result
in { type: "ok", value: } then process_result(value)
in { type: "error", message: } then log_error(message)
end
puts output
end type Result =
| { type: "ok"; value: T }
| { type: "error"; message: string };
function handle(result: Result): T | string {
switch (result.type) {
case "ok": return result.value;
case "error": return `Error: ${result.message}`;
default:
// Assigning to never causes a compile error if a case is unhandled:
const _check: never = result;
throw new Error(`Unhandled variant: ${JSON.stringify(_check)}`);
}
}
console.log(handle({ type: "ok", value: 42 }));
console.log(handle({ type: "error", message: "not found" })); The
never assignment in default is the standard TypeScript exhaustiveness pattern. If you add a new variant to Result but forget to handle it in the switch, the compiler errors at the _check assignment β caught at build time rather than runtime. Equivalent to Ruby's NoMatchingPatternError but before the code ships.Utility Types
Built-in Type Transformations
# No equivalent in plain Ruby or RBS.
# Sorbet has T::Struct for immutable value objects.
# Ruby keyword arguments with defaults approximate Partial.
# RBS has no built-in type transformation utilities.
puts "Ruby: no direct equivalent" interface User { id: number; name: string; email: string; age: number; }
// Partial β all properties become optional (useful for PATCH payloads)
const patch: Partial = { name: "Bob" };
// Pick β keep only named keys
const card: Pick = { id: 1, name: "Alice" };
// Omit β remove named keys
const publicUser: Omit = { id: 1, name: "Alice", age: 30 };
// Record β object with uniform value type
const scores: Record = { alice: 95, bob: 87 };
// ReturnType β extract the return type of a function
function fetchUser(): Promise { return Promise.resolve({} as User); }
type FetchResult = ReturnType; // Promise
// NonNullable β strip null and undefined
type DefiniteString = NonNullable; // string
console.log(patch);
console.log(card);
console.log(publicUser);
console.log(scores); Utility types are built-in generic transformations that derive new types from existing ones. They keep type definitions DRY. Use
Partial<T> for PATCH endpoints, Readonly<T> for immutable config objects, Pick/Omit to expose only the fields a function needs.Structural Typing vs. Duck Typing
Structural vs. Nominal
# Pure duck typing β any object with the right methods works
class Duck
def quack = "Quack!"
def walk = "Waddle"
end
class Person
def quack = "I'm quacking like a duck!"
def walk = "Walking normally"
end
def make_it_quack(duck) = duck.quack
puts make_it_quack(Duck.new)
puts make_it_quack(Person.new)
puts make_it_quack(Object.new.tap { |o|
o.define_singleton_method(:quack) { "Squeak!" }
o.define_singleton_method(:walk) { "Shuffle" }
}) interface Quackable {
quack(): string;
walk(): string;
}
class Duck {
quack() { return "Quack!"; }
walk() { return "Waddle"; }
// No `implements Quackable` β structural typing checks the shape
}
class Person {
quack() { return "I'm quacking like a duck!"; }
walk() { return "Walking normally"; }
}
function makeItQuack(duck: Quackable): string {
return duck.quack();
}
console.log(makeItQuack(new Duck()));
console.log(makeItQuack(new Person()));
// Plain object also satisfies the interface:
console.log(makeItQuack({ quack: () => "Honk!", walk: () => "Stride" })); TypeScript's structural typing is the typed-language equivalent of Ruby's duck typing. An object satisfies a type if it has the required shape β
implements is optional documentation. Contrast with Java and C#'s nominal typing where you must declare implements. Both Sorbet and RBS/Steep use structural typing, matching Ruby's duck-typing philosophy.tsconfig & Strict Mode
Project Configuration
# RBS Inline + Steep: Steepfile at project root
# target :app do
# signature "sig" # directory of .rbs files (or use rbs-inline)
# check "lib"
# check "app"
# end
# Sorbet uses: # typed: strict at top of each .rb file
# and sorbet/config for global options {
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
} Always start with
"strict": true β it enables noImplicitAny, strictNullChecks, and several others. noUncheckedIndexedAccess is particularly valuable: it makes array[0] return T | undefined rather than T, reflecting reality. It's not included in strict but highly recommended. Generate an initial config with npx tsc --init.Declaration Files & Modules
Declaration Files (.d.ts / .rbs)
# .rbs files serve exactly the same purpose for Steep β
# they describe types without implementations
# sig/greeter.rbs
class Greeter
def greet: (String name) -> String
end // .d.ts files β type declarations shipped alongside JavaScript libraries
// You rarely write these by hand; they come from @types/* packages
// Example: what a declaration file looks like
declare function greet(name: string): string;
declare class EventEmitter {
on(event: string, listener: (...args: unknown[]) => void): this;
emit(event: string, ...args: unknown[]): boolean;
}
// Module augmentation β adding properties to a library's types
declare module "express-serve-static-core" {
interface Request {
currentUser?: User;
}
} .d.ts files are the TypeScript equivalent of Ruby's .rbs files β they describe the shape of existing code without implementations. Module augmentation (adding to an existing library's types) is the safe way to extend third-party type definitions. Install community-maintained types with npm install --save-dev @types/node.Modules & Imports
# math_utils.rb
module MathUtils
def self.square(n) = n * n
PI = Math::PI
end
# main.rb
require_relative 'math_utils'
puts MathUtils.square(4) # => 16
puts MathUtils::PI # => 3.14... // math_utils.ts
export function square(n: number): number { return n * n; }
export const PI = Math.PI;
export type Numeric = number | bigint; // type-only export
// main.ts
import { square, PI } from "./math_utils.js"; // .js extension in NodeNext mode
import type { Numeric } from "./math_utils.js"; // type-only β erased at compile time
import * as MathUtils from "./math_utils.js"; // namespace import
// Re-exporting:
export { square } from "./math_utils.js";
export type { Numeric } from "./math_utils.js"; import type / export type are completely erased at compile time and never appear in the output JavaScript β important for avoiding circular dependency issues and for bundler tree-shaking. In NodeNext module mode, import paths must use .js extensions (even though the source files are .ts) because Node resolves the compiled output.β Gotchas for Rubyists
any Is Contagious
# Ruby: duck typing is always "safe" β NoMethodError fires loudly
value = { user: { name: "Alice" } }
puts value.dig(:user, :name) # => "Alice"
bad = nil
# bad.user.name # => NoMethodError: undefined method 'user' β explicit error // any silences the type checker entirely
function parse(data: any): any {
return data.user?.name ?? "(missing)";
}
// Fix: use unknown and narrow before use
function parseSafe(data: unknown): string {
if (
typeof data !== "object" || data === null || !("user" in data) ||
typeof (data as { user: unknown }).user !== "object"
) {
return "(unexpected shape)";
}
return (data as { user: { name: string } }).user.name;
}
const good = { user: { name: "Alice" } };
const bad = { noUser: true };
console.log(parse(good));
console.log(parse(bad));
console.log(parseSafe(good));
console.log(parseSafe(bad)); A single
any can cascade through your codebase, removing type safety from everything it touches. Use unknown for untrusted data and narrow with type guards, or use a runtime validation library (Zod, Valibot) to connect external data to your TypeScript types.Type Assertions Are Not Runtime Casts
"42".to_i # actual conversion β returns Integer 42
42.to_s # actual conversion β returns String "42"
puts "42".to_i.class
puts 42.to_s.class // as T does NOT convert the value β it only changes the compiler's belief
const maybeValue: unknown = 42;
const treated = maybeValue as string;
console.log("Runtime type:", typeof treated, treated); // still number 42!
// Double assertion bypasses all safety (avoid!)
const broken = ("hello" as unknown) as number;
console.log("Compiler thinks number:", typeof broken, broken);
// Still "hello" at runtime β as T is purely compile-time
// Non-null assertion ! β "I promise this isn't null"
const names = ["Alice", "Bob"];
const first = names[0]!;
console.log("First name:", first); as Type and ! are compile-time lies to the type checker β they don't transform values and can't fail gracefully at runtime. Reserve them for cases where you genuinely have information the compiler doesn't, and prefer runtime checks (if (element !== null)) wherever possible.Structural Typing Can Surprise You
class UserId
def initialize(id) = @id = id
end
class ProductId
def initialize(id) = @id = id
end
# These are distinct classes β you cannot mix them up
user_id = UserId.new(1)
product_id = ProductId.new(1)
puts user_id.class
puts product_id.class
puts user_id.class == product_id.class // Two types with the same shape are interchangeable!
type UserId = { id: number };
type ProductId = { id: number };
interface User { name: string }
function getUser(id: UserId): User { return { name: "Alice" }; }
const productId: ProductId = { id: 42 };
const user = getUser(productId); // No error β same shape = same type
console.log("Structural typing: ProductId passes as UserId:", user);
// Fix: branded types (nominal typing pattern)
type BrandedUserId = number & { readonly __brand: "UserId" };
type BrandedProductId = number & { readonly __brand: "ProductId" };
const makeUserId = (n: number) => n as BrandedUserId;
const makeProductId = (n: number) => n as BrandedProductId;
function getUserById(id: BrandedUserId): User { return { name: "Alice" }; }
console.log("Branded UserId:", getUserById(makeUserId(42)));
// getUserById(makeProductId(42)); // TypeScript Error at compile time! Structural typing means two types with identical shapes are interchangeable. Ruby classes are always nominally distinct β
UserId.new(1) and ProductId.new(1) are different types even if they hold the same data. Use "branded types" (intersecting with a unique phantom field) when nominal type safety matters.readonly Is Shallow
person = { name: "Alice", scores: [1, 2, 3] }.freeze
begin
person[:name] = "Bob" # FrozenError
rescue => error
puts error.message
end
person[:scores].push(4) # works β freeze is shallow
puts person[:scores].inspect const person: Readonly = {
name: "Alice",
scores: [1, 2, 3],
};
// person.name = "Bob"; // Error! β caught at compile time
person.scores.push(4); // Works! readonly is shallow β scores itself is mutable
console.log(person.name); // "Alice"
console.log(person.scores); // [1, 2, 3, 4] β mutation worked!
// Deep readonly: as const on literals
const config = {
host: "localhost",
ports: [8080, 8443],
} as const;
// config.host = "remote"; // Error! deeply readonly
// config.ports.push(443); // Error! deeply readonly
console.log(config.host, config.ports); Readonly<T> and the readonly modifier are shallow, exactly like Ruby's freeze. Use as const for literal objects and arrays β it makes all nested values readonly and narrows literal types to their narrowest form ("localhost" not string).TypeScript Offers No Runtime Protection
# Without type gems, Ruby has no compile-time enforcement
def greet(name)
raise TypeError, "Expected String, got #{name.class}" unless name.is_a?(String)
"Hello, #{name}!"
end
puts greet("Alice") # Hello, Alice!
greet(42) rescue puts $! # TypeError: Expected String, got Integer // Types are erased β the output is plain JavaScript with no type checks
interface User { name: string; age: number; }
function greet(user: User): string {
return `Hello, ${user.name} (age ${user.age})`;
}
// JSON.parse returns `any` β TypeScript cannot verify its shape
const data = JSON.parse('{"name":"Alice"}'); // age is MISSING!
console.log(greet(data)); // No compile error β runs fine
console.log("user.age:", data.age); // undefined at runtime!
// To fix: validate external data at runtime (Zod, Valibot, etc.):
// const UserSchema = z.object({ name: z.string(), age: z.number() });
// const user = UserSchema.parse(data); // throws ZodError β age missing TypeScript's type safety is compile-time only and completely invisible in the JavaScript runtime. Data from APIs,
JSON.parse, localStorage, and environment variables is any unless you validate it. Use Zod, Valibot, or io-ts to validate external data at runtime and connect it to your TypeScript types.Enums Have Surprising Runtime Behaviour
module Status
PENDING = :pending
ACTIVE = :active
end
puts Status::PENDING
puts Status.constants.inspect enum Status { Pending = "pending", Active = "active" }
// Numeric enums create a reverse mapping β often surprising
enum Direction { North, South, East, West } // values: 0, 1, 2, 3
console.log(Direction[0]); // "North" β reverse lookup exists!
console.log(Direction["North"]); // 0
// String enums do NOT create a reverse mapping
console.log(Status.Pending); // "pending"
console.log((Status as any)["pending"]); // undefined β no reverse lookup!
// The generated runtime object for a numeric enum:
const directionKeys = Object.keys(Direction).filter(k => isNaN(Number(k)));
console.log("Direction names:", directionKeys); Numeric enums generate a bidirectional runtime object with reverse mappings β often confusing and the source of subtle bugs. String enums are safer and more debuggable but add runtime overhead. For most cases, a plain union type (
type Status = "pending" | "active") produces identical behaviour with less complexity and zero runtime overhead.