Ruby.CodeCompared.To/TypeScript

An interactive executable cheatsheet for Rubyists learning TypeScript

Ruby 4.0 TypeScript 6.0
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.