바이브코딩만 해봤어도, 이 정도면 읽고 쓸 수 있습니다
직접 타이핑해본 적이 별로 없어도 괜찮습니다. 기본 문법 14개만 Rust와 TypeScript를 나란히 놓고 봅니다 — 복잡한 응용은 다 빼고, "이 코드가 뭘 하는 코드인지 알아보는" 수준까지만. 각 항목 아래 짧은 메모는 두 언어의 사고방식이 어디서 갈리는지를 짚어줍니다.
딱 5개만 먼저 외우고 시작
시간 없으면 이 카드 5개만이라도. 아래 14개 항목은 결국 이 5가지의 구체적인 예시입니다.
mut 기본값이 뒤바뀜
Rust는 기본 불변, mut를 붙여야 가변. TS는 반대로 let이 가변 기본값이고 const가 불변.
match는 다 다뤄야 함
match는 모든 경우를 처리 안 하면 컴파일 에러. switch/삼항연산자는 그런 강제가 없음.
Option·Result vs null·예외
Rust는 "없을 수도/실패할 수도"를 타입으로 강제. TS는 undefined 체크나 catch를 깜빡해도 컴파일이 통과.
소유권 vs GC
Rust는 "누가 이 값을 갖고 있는지"를 컴파일 타임에 추적. TS는 가비지 컬렉터가 알아서 치움.
세미콜론 없는 마지막 줄 = 반환값
Rust 함수 마지막 식에서 세미콜론을 빼면 그게 반환값. TS는 항상 return이 필요.
변수 & 타입
이름은 비슷한데 역할이 정반대인 게 있으니 여기서 먼저 걸러둡니다.
let x = 5; // 불변(기본값) — 재할당하면 에러 let mut y = 10; // mut 없으면 가변 불가 y = 20; let name: &str = "won"; // 타입은 보통 추론, 명시도 가능 const MAX: u32 = 100; // const는 타입 명시 필수
let x = 5; // 재할당 가능 (기본) const y = 10; // 재할당 불가 — 실무 기본값 let name: string = "won"; const MAX: number = 100;
let)이고 mut를 붙여야 가변입니다. TS는 정반대로 let이 가변 기본값이고 const가 불변이에요 — 글자는 비슷한데 역할이 뒤바뀐 대표 사례입니다.
함수
시그니처 쓰는 법과 반환값 처리 방식만 보면 됩니다.
fn add(a: i32, b: i32) -> i32 {
a + b // 세미콜론 없으면 이 줄이 곧 반환값
}
fn greet(name: &str) {
println!("hello, {}", name);
}function add(a: number, b: number): number {
return a + b; // return 필수
}
const greet = (name: string): void => {
console.log(`hello, ${name}`);
};return은 중간에 조기 리턴할 때만 씁니다. TS/JS는 항상 return을 명시해야 해요.
구조체 vs 인터페이스 + 클래스
데이터 모양을 정의하고 그 위에 동작을 붙이는 방법입니다.
struct User {
name: String,
age: u32,
}
impl User {
fn greet(&self) -> String {
format!("hi, {}", self.name)
}
}
let u = User { name: "won".to_string(), age: 20 };
println!("{}", u.greet());interface User {
name: string;
age: number;
}
function greet(u: User): string {
return `hi, ${u.name}`;
}
const u: User = { name: "won", age: 20 };
console.log(greet(u));struct)와 동작(impl)이 분리돼 있습니다. TS interface는 모양(shape)만 정의할 뿐이라, 메서드를 진짜로 붙이려면 class를 써야 해요.
enum
Rust enum은 단순 상수 나열이 아니라 각 값마다 데이터를 실을 수 있는 컨테이너입니다.
enum Status {
Loading,
Success(String), // variant마다 다른 데이터를 담음
Error { code: u32 },
}
let s = Status::Success("done".to_string());
match s {
Status::Loading => println!("loading"),
Status::Success(msg) => println!("ok: {}", msg),
Status::Error { code } => println!("err {}", code),
}type Status =
| { kind: "loading" }
| { kind: "success"; msg: string }
| { kind: "error"; code: number };
const s: Status = { kind: "success", msg: "done" };
switch (s.kind) {
case "loading": console.log("loading"); break;
case "success": console.log(`ok: ${s.msg}`); break;
case "error": console.log(`err ${s.code}`); break;
}enum 키워드가 있지만, 실무에서는 이렇게 kind 필드로 구분하는 union 패턴이 더 흔합니다.
조건 & 매칭
if는 거의 똑같고, match/switch에서 차이가 갈립니다.
let n = 7;
if n % 2 == 0 {
println!("even");
} else {
println!("odd");
}
let label = match n {
0 => "zero",
1..=9 => "single digit", // 범위 매칭
_ => "big", // _ 는 default
};const n = 7;
if (n % 2 === 0) {
console.log("even");
} else {
console.log("odd");
}
const label =
n === 0 ? "zero" :
n < 10 ? "single digit" :
"big";match는 모든 경우를 다뤄야 컴파일이 통과합니다(exhaustive) — 하나라도 빠뜨리면 에러예요. switch나 삼항연산자는 그런 강제가 없어서, 케이스를 실수로 빠뜨려도 조용히 통과합니다.
반복문
range와 컬렉션 순회를 같은 for 문 하나로 처리한다는 점이 TS의 for...of와 닮았습니다.
for i in 0..5 { // 0,1,2,3,4 (5는 미포함)
println!("{}", i);
}
let v = vec![1, 2, 3];
for x in &v { // 참조로 순회 — v는 여전히 살아있음
println!("{}", x);
}
let mut i = 0;
while i < 3 {
i += 1;
}for (let i = 0; i < 5; i++) {
console.log(i);
}
const v = [1, 2, 3];
for (const x of v) {
console.log(x);
}
let i = 0;
while (i < 3) {
i++;
}컬렉션
map/filter는 이름과 쓰임이 거의 똑같아서 제일 편한 지점입니다.
let mut v: Vec<i32> = vec![1, 2, 3];
v.push(4);
let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect();
let evens: Vec<&i32> = v.iter().filter(|x| *x % 2 == 0).collect();
use std::collections::HashMap;
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".to_string(), 1);const v: number[] = [1, 2, 3];
v.push(4);
const doubled: number[] = v.map((x) => x * 2);
const evens: number[] = v.filter((x) => x % 2 === 0);
const m = new Map<string, number>();
m.set("a", 1);.iter()로 순회 시작을 명시하고 .collect()로 마지막에 결과 타입을 정해줘야 컴파일됩니다. TS는 배열 메서드가 바로 새 배열을 돌려줘서 한 단계 짧게 느껴져요.
Option · Result vs null · try-catch
두 언어의 사고방식이 가장 크게 갈리는 지점입니다.
fn find(v: &[i32], target: i32) -> Option<usize> {
for (i, x) in v.iter().enumerate() {
if *x == target { return Some(i); }
}
None
}
match find(&[1, 2, 3], 2) {
Some(i) => println!("found at {}", i),
None => println!("not found"),
}
fn parse(s: &str) -> Result<i32, String> {
s.parse::<i32>().map_err(|_| "bad number".to_string())
}
let n = parse("42")?; // ? 는 에러면 즉시 함수 리턴function find(v: number[], target: number): number | undefined {
const i = v.indexOf(target);
return i === -1 ? undefined : i;
}
const i = find([1, 2, 3], 2);
if (i !== undefined) console.log(`found at ${i}`);
function parse(s: string): number {
const n = Number(s);
if (Number.isNaN(n)) throw new Error("bad number");
return n;
}
try {
const n = parse("42");
} catch (e) {
console.error(e);
}Option), "실패할 수도"(Result) 있다는 걸 타입으로 강제해서 컴파일러가 처리 누락을 잡아줍니다. TS/JS는 undefined 체크를 깜빡하거나 catch를 안 붙여도 컴파일이 그냥 통과해요. ? 연산자는 "Result가 에러면 그 자리에서 함수를 즉시 리턴"을 한 글자로 압축한 문법입니다.
클로저
화살표 함수와 개념적으로 거의 1:1 대응입니다.
let add_one = |x: i32| x + 1;
println!("{}", add_one(4));
let factor = 3;
let scale = |x: i32| x * factor; // 바깥 변수를 자동으로 캡처const addOne = (x: number) => x + 1;
console.log(addOne(4));
const factor = 3;
const scale = (x: number) => x * factor; // 마찬가지로 자동 캡처트레이트 vs 인터페이스 구현
다형성을 표현하는 역할이 거의 같습니다.
trait Greet {
fn greet(&self) -> String;
}
struct Dog;
impl Greet for Dog {
fn greet(&self) -> String {
"woof".to_string()
}
}
fn say_hi(g: &impl Greet) { // 트레이트를 만족하는 아무 타입이나
println!("{}", g.greet());
}interface Greet {
greet(): string;
}
class Dog implements Greet {
greet(): string {
return "woof";
}
}
function sayHi(g: Greet): void {
console.log(g.greet());
}impl로 트레이트를 붙일 수 있다는 것 — TS는 클래스를 선언하는 시점에 implements를 못 박아야 합니다.
제네릭
<T> 문법 자체는 거의 동일합니다.
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut max = &list[0];
for item in list {
if item > max { max = item; }
}
max
}
let nums = vec![3, 7, 2];
println!("{}", largest(&nums));function largest<T>(list: T[]): T {
let max = list[0];
for (const item of list) {
if (item > max) max = item;
}
return max;
}
const nums = [3, 7, 2];
console.log(largest(nums));T: PartialOrd처럼 "이 타입은 비교 가능해야 함"을 트레이트 경계(bound)로 명시해야 컴파일된다는 것 — TS는 그런 제약을 안 걸면 일단 넘어가고 런타임에서야 문제가 드러날 수 있습니다.
소유권 — Rust에만 있는 개념
면접에서 "Rust를 왜 골랐냐"는 질문의 답이 여기 있습니다.
// String은 힙에 저장되는 소유 타입 — 정수 같은 Copy 타입은 이 규칙에서 열외 let s1 = String::from("hello"); let s2 = s1; // s1의 소유권이 s2로 "이동"(move) — s1은 이제 못 씀 // println!("{}", s1); // 컴파일 에러! fn print_len(s: &String) { // &로 "빌려오기"(borrow) — 소유권은 호출자 것 그대로 println!("{}", s.len()); } print_len(&s2); // s2는 여전히 살아있음 fn add_world(s: &mut String) { // &mut 로 가변 빌림 — 값을 바꿀 수 있음 s.push_str(" world"); }
async / await
키워드와 흐름은 거의 동일하고, 런타임이 필요한지 여부만 다릅니다.
async fn fetch_name() -> String {
"won".to_string()
}
#[tokio::main]
async fn main() {
let name = fetch_name().await;
println!("{}", name);
}async function fetchName(): Promise<string> {
return "won";
}
async function main() {
const name = await fetchName();
console.log(name);
}
main();async fn을 실제로 돌려줄 런타임(tokio 같은)이 따로 필요합니다. TS는 Node나 브라우저에 런타임이 이미 내장돼 있어서 별도 설정이 없습니다.
출력 / 문자열 포맷
자리표시자 문법만 다르고 하는 일은 같습니다.
let name = "won";
let age = 20;
println!("{} is {} years old", name, age);
println!("{name} is {age} years old"); // 이름 있는 캡처(최신 문법)
let s = format!("{}-{}", name, age); // 문자열로 "만들기"만, 출력은 안 함const name = "won";
const age = 20;
console.log(`${name} is ${age} years old`);
const s = `${name}-${age}`;println!/format!은 {} 자리에 인자를 순서대로 채우거나, {name}처럼 변수 이름을 직접 넣을 수 있습니다. TS 템플릿 리터럴의 ${}랑 하는 일은 똑같은데 문법만 다릅니다.