C
TypeScript/Async/Lesson 03

Promise<T> — Type-Safe Async with TypeScript

30 min·theory
This chapter
3/7
TypeScript

Promise — Type-Safe Async with TypeScript

💡 Why Learn This? — One Step Beyond JS Promises

🎯 A JS Promise does not know what `user` is inside `.then(user => ...)` — no IDE autocomplete, and typos only blow up at runtime.
💼 A TS `Promise` locks in the type of the resolved value at the `T` position — the IDE knows in advance, and incorrect usage is rejected at compile time.
90% of real-world API functions return a Promise. A typed Promise determines the reliability of the calling code.
🔗 Tools like `async/await` and `Promise.all` also deliver their true value only when types are locked in (`Promise.all` even infers tuple types).
🏢 실무에서는
Server Components, Server Actions, and API routes in Next.js and React are all `async` functions. Without explicitly stating return types, callers end up with forced casts like `as User` every time, and as these accumulate, the type system loses its meaning. In practice, explicitly specifying return types such as `Promise`, `Promise`, and `Promise` is standard.

Promise — Pinning the Type of the 'Resolved Value' with Generics

What Is the T in Promise?

A JS Promise is 'an object that promises a value arriving later.' A TS Promise takes that further by telling the compiler 'what type of value will arrive.'

ts
Promise<string>   // a string will arrive later
Promise<User>     // a User object will arrive later
Promise<User[]>   // a User array will arrive later
Promise<void>     // it will eventually finish, but there is no value to receive

What Changes When T Is Pinned?

ts
const orderPizza = (): Promise<string> => { ... };

orderPizza().then((pizza) => {
  //              ^^^^ ← IDE infers string here
  pizza.toUpperCase(); // ✅ string method, autocomplete works
  pizza.toFixed(2);    // ❌ toFixed does not exist on string — compilation rejected
});

In plain JS, pizza.toFixed(2) would pass compilation → TypeError at runtime → caught by tests if you are lucky, or it blows up in production if you are not.

An async Function's Return Type Is Automatically Wrapped in Promise

ts
async function getUser(): Promise<User> {
  return { id: 1, name: 'Hong Gildong' }; // ← returns User, but
}                                          //   return type is Promise<User>

Any function with the async keyword always returns a Promise. Therefore you must write Promise<...> as the return type (or leave it to inference).

Explicit Types vs. Inferred Types

In practice you use both:

ts
// Explicit — intent is visible just by reading the function signature (recommended for public APIs)
const fetchUser = (id: number): Promise<User> => { ... };

// Inferred — suitable for short callbacks and internal helpers
const delay = (ms: number) =>
  new Promise<void>((r) => setTimeout(r, ms));
// return type: inferred as Promise<void>

Promise.all Infers Down to Tuple Types

ts
const [pizza, cola, fries] = await Promise.all([
  order('🍕'),  // Promise<string>
  order('🥤'),  // Promise<string>
  order('🍟'),  // Promise<string>
]);
// 👆 TS infers [string, string, string] tuple
// If types differ, it captures them precisely as e.g. [string, number, User]

In JS, even if you receive the result array, the type of each element is unknown. TS preserves the type at each position.

💻 🅰️ The Classic JS Approach (No Type Information)
// ============================================
// ❌ JS approach — IDE doesn't know the types of user, orders, items
// ============================================

function fetchUser(id) {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ id, name: 'Hong Gil-dong' }), 300);
  });
}

function fetchOrders(uid) {
  return new Promise((resolve) => {
    setTimeout(() => resolve([{ id: 1, item: 'Book' }]), 300);
  });
}

fetchUser(42)
  .then((user) => {
    // user.name?  user.id?  user.email?  No IDE autocomplete
    console.log(user);
    return fetchOrders(user.id);
  })
  .then((orders) => {
    // IDE doesn't know if orders is an array or an object
    // Typo in orders[0].item only discovered at runtime
    console.log(orders);
  });

// Danger points:
// - user.naem (typo) → Compiles, undefined at runtime
// - orders.item   (accessing array like an object) → undefined at runtime
// - fetchUser('42') (string for id) → Compiles, behavior subtly off
💻 🅱️ The TypeScript Approach (Promise · interface · Generics)
// ============================================
// ✅ TS approach — types are explicit, IDE helps at each step
// Run: tsx promise-demo.ts
// ============================================

// Example 1: Simplest Promise
const orderPizza = (): Promise<string> => {
  //              ↑ Return type: Promise<string>
  return new Promise<string>((resolve) => {
    //                ↑ Type of value the Promise will hold
    console.log('📞 Called to order');
    setTimeout(() => resolve('🍕'), 1000);
  });
};
pizza order().then((pizza: string) => {
  //                ↑ Type of received value
  console.log('Received:', pizza);
});

// Example 2: Chaining (leveraging type inference)
const order = (food: string, time: number): Promise<string> =>
  new Promise<string>((resolve) => setTimeout(() => resolve(food), time));

order('🍕 Pizza', 500)
  .then((food) => {              // Auto-inferred: string
    console.log('1️⃣ Received:', food);
    return order('🥤 Coke', 500);
  })
  .then((food) => {
    console.log('2️⃣ Received:', food);
    return order('🍟 Fries', 500);
  })
  .then((food) => console.log('3️⃣ Received:', food));

// Example 3: Error handling (explicit error type)
const orderFood = (willSucceed: boolean): Promise<string> => {
  return new Promise<string>((resolve, reject) => {
    setTimeout(() => {
      if (willSucceed) resolve('🍕 Pizza arrived!');
      else reject(new Error('😭 Store is closed'));
    }, 500);
  });
};

orderFood(false)
  .then((pizza) => console.log('Success:', pizza))
  .catch((error: Error) => console.log('❌ Failed:', error.message));

// Example 4: Real-world code (type definition with interface)
interface User {
  id: number;
  name: string;
}
interface Order {
  id: number;
  item: string;
}

const wait = (ms: number): Promise<void> =>
  new Promise((r) => setTimeout(r, ms));

const fetchUser = (id: number): Promise<User> =>
  wait(300).then(() => ({ id, name: 'Hong Gil-dong' }));

const fetchOrders = (uid: number): Promise<Order[]> =>
  wait(300).then(() => [{ id: 1, item: 'Book' }]);

fetchUser(42)
  .then((user: User) => {
    // user.name?  user.id?  ← IDE autocomplete OK
    // user.naem  ← Compile immediately rejected (red underline on this line)
    return fetchOrders(user.id);
  })
  .then((orders: Order[]) => {
    // orders[0].item ← inferred
  });

// Example 5: async/await (TS recommended approach)
const task = (name: string, ms: number): Promise<string> =>
  new Promise((r) => setTimeout(() => r(name), ms));
const execute = async (): Promise<void> => {
  const a: string = await task('A', 200);
  const b: string = await task('B', 200);
  const c: string = await task('C', 200);
  console.log(a, b, c);
};

// Example 6: Promise.all — inferred as tuple type
const [pizza, coke, fries]: [string, string, string] = await Promise.all([
  order('🍕', 1000),
  order('🥤', 1000),
  order('🍟', 1000),
]);
// 👆 TS preserves positional types — if mixed, it's precisely like [string, number, User]
// Example 7: Generic Promise helper (accepts any type)
const delay = <T>(value: T, ms: number): Promise<T> =>
  new Promise((r) => setTimeout(() => r(value), ms));

const numberValue: number = await delay(42, 300);              // T = number
const stringValue: string = await delay('Hello', 300);          // T = string
const objectValue: { name: string } = await delay({ name: 'Hong Gil-dong' }, 300);

💡 💡 Key Differences: JS ↔ TS

1. Always pin <T> on new Promise()

ts
new Promise<string>((resolve) => ...) // forces resolve to accept only string
new Promise((resolve) => ...)         // T is inferred as unknown (dangerous)

2. It is better to explicitly annotate the return type of async functions

Public API functions should reveal their intent just from the signature.

ts
async function getUser(id: number): Promise<User> { ... }

3. Receive the result of Promise.all as a tuple

ts
const [user, orders] = await Promise.all([fetchUser(1), fetchOrders(1)]);
// user: User, orders: Order[] ← automatic

4. The error in catch is unknown (TS 4.4+)

ts
try { ... } catch (err) {
  if (err instanceof Error) console.log(err.message); // ✅ type narrowing
  // accessing err.message directly ← ❌ unknown has no message property
}

5. Write a generic helper once and use it forever

ts
const delay = <T>(value: T, ms: number): Promise<T> =>
  new Promise((r) => setTimeout(() => r(value), ms));
// Accepts number, string, User, or anything — type is preserved at the call site

⚡ Try It Yourself — Promise (Runnable Version Without Types)

This is the **runnable version with type annotations stripped** from the 🅱️ TS code above. The runtime behavior is identical — the difference is whether the IDE catches mistakes vs. things blowing up at runtime. 💡 To run it with the real TS compiler → paste the 🅱️ code above into the [TypeScript Playground](https://www.typescriptlang.org/play).
✏️ JS 코드
📟 Console output
▶ Press the Run button
⚠️ Runs in a browser sandbox — only console.log() is supported; alert/fetch are not.

Check Your Understanding

In TypeScript, what does `<User>` in `Promise<User>` mean?
💡 The `T` in `Promise<T>` is the **type of the value passed when it resolves**. As a result, the `value` in `.then((value) => ...)` is automatically inferred as `T`. The error on the reject side has no separate annotation due to limitations of TypeScript's type system, and must be received as `unknown` in `catch` and then narrowed.
Promise<T> — JS vs TS Differences - TypeScript