템플릿 리터럴 타입으로 prop 이름을 만든다 — 매핑 타입이 union 멤버를 하나씩 K 로 돌고, as 가 `render${K}` 로 이름을 바꾼다.1

import * as React from 'react'

type RenderPropNames = 'Title' | 'Content' | 'Actions'

type RenderProps = {
  [K in RenderPropNames as `render${K}`]: () => React.ReactNode
}

type Props = RenderProps

/**
 * @example
 *
 * ```tsx
 * <DialogComponent
 *   renderTitle={() => <h2>Title</h2>}
 *   renderContent={() => <p>Content</p>}
 *   renderActions={() => (
 *     <div>
 *       <button onClick={handleClose}>Close</button>
 *       <button onClick={handleSubmit}>Submit</button>
 *     </div>
 *   )}
 * />
 * ```
 */
function Dialog({
  renderTitle,
  renderContent,
  renderActions,
}: Props) {
  return (
    <div data-scope="root">
      <div data-part="content">
        {renderTitle()}
        {renderContent()}
      </div>
      <div data-part="actions">{renderActions()}</div>
    </div>
  )
}

Footnotes

  1. TypeScript Handbook — Mapped Types, Key Remapping via as (TS 4.1+): “You can leverage features like template literal types to create new property names from prior ones”. K 가 멤버 하나일 때 템플릿 리터럴은 “produces a new string literal type by concatenating the contents”. ↩

#350

대화 상자(Dialog) 유형

Modal - 상호작용 차단

  • System Modal: 닫기 전까지 다른 작업 불가
  • Application Modal: 프로그램 일시 중단
  • Document Modal: 부모 창만 차단 (macOS 시트)

Modeless - 상호작용 허용 (예: 툴바)

모달은 워크플로우 방해 → 비필수 작업에는 모델리스 선호1

313.md 참고


Footnotes

  1. Dialog box - Wikipedia: “A modal dialog interrupts the main workflow.” / “Non-modal or modeless dialog boxes are used when the requested information is not essential to continue…” ↩

#349

Vitest/Jest Fake Timers로 시간 제어

describe('time-dependent tests', () => {
  beforeEach(() => {
    vi.useFakeTimers() // jest.useFakeTimers()
  })

  afterEach(() => {
    vi.useRealTimers() // jest.useRealTimers()
  })

  it('특정 시간에 동작 확인', () => {
    vi.setSystemTime(new Date(2000, 1, 1, 13)) // 13시 설정
    expect(purchase()).toEqual({ message: 'Success' })
  })

  it('영업시간 외 동작', () => {
    vi.setSystemTime(new Date(2000, 1, 1, 19)) // 19시 설정
    expect(purchase()).toEqual({ message: 'Error' })
  })
})

@sinonjs/fake-timers 기반. Date, setTimeout 등 모킹.1


Footnotes

  1. vi - Vitest: “The implementation is based internally on @sinonjs/fake-timers.” / The Jest Object: “Fake timers implementation is backed by @sinonjs/fake-timers.” ↩

#347

오버로드는 ReturnType·infer 로 하나를 고를 수 없다 — 마지막 시그니처만 본다. 시그니처를 알면 할당 가능한지로 고른다.

T extends R 는 “R 처럼 불러도 T 가 받아내나?“를 묻는다. 오버로드된 T 는 시그니처 중 하나만 받아내면 된다. 시그니처 하나가 받아내는 조건1은 셋이다.

  1. R 이 넘기는 인자보다 더 요구하지 않는다 — 남는 인자는 버리면 된다
  2. 받는 인자의 타입이 맞는다
  3. 돌려주는 값이 R 의 반환 자리에 들어간다
type Year = {
  year(): number
  year(u: string): string
}

// 추출이 아니라 할당 검사: 받아내는 시그니처가 있으면 R 을 그대로 돌려준다
type ReturnTo<T, R> = T extends R ? R : never

type GetYear = ReturnTo<Year['year'], () => number> // () => number
type SetYear = ReturnTo<Year['year'], (u: string) => string> // (u: string) => string
type Loose = ReturnTo<Year['year'], (u: number) => number> // 통과 — year() 가 u 를 버리고 받아낸다 (1)

type A = ReturnType<Year['year']> // string — 마지막만. 순서를 바꾸면 number
type B = Year['year'] extends () => infer R ? R : never // never

infer 는 묻지 않고 꺼낸다 — 마지막 시그니처에서 R 을 정하고, 인자 목록으로 오버로드를 고르는 건 불가능하다2. B 는 R = string 을 꺼낸 뒤 () => string 을 받아내는 시그니처가 없어서 never 다.

직접 정의하는 쪽이면 오버로드 대신 제네릭 하나로 — year<U extends [] | [string]>(...a: U): U extends [] ? number : string 이면 ReturnType<typeof y.year<[]>> 로 꺼낸다.


Footnotes

  1. Type Compatibility — Comparing two functions. 오버로드: “each overload in the target type must be matched by a compatible signature on the source type”. 인자 버리기: “ignoring extra function parameters is actually quite common in JavaScript”. ↩

  2. Conditional Types — Inferring Within Conditional Types: “inferences are made from the last signature … It is not possible to perform overload resolution based on a list of argument types.” ↩

#345

DFS (깊이 우선 탐색)

// 재귀 방식
function dfs(graph, node, visited = new Set()) {
  visited.add(node)
  console.log(node)
  graph[node].forEach((neighbor) => {
    if (!visited.has(neighbor)) dfs(graph, neighbor, visited)
  })
}

// 스택 방식
function dfsStack(graph, startNode) {
  const stack = [startNode]
  const visited = new Set()

  while (stack.length > 0) {
    const node = stack.pop()

    if (!visited.has(node)) {
      console.log(node)
      visited.add(node)
      graph[node]
        .slice()
        .reverse()
        .forEach((neighbor) => {
          if (!visited.has(neighbor)) stack.push(neighbor)
        })
    }
  }
}

const graph = {
  0: [1, 2],
  1: [0, 3, 4],
  2: [0, 5],
  3: [1],
  4: [1],
  5: [2],
}
dfs(graph, 0) // 0, 1, 3, 4, 2, 5
#344

JavaScript delete 연산자 — configurable 한 자기 속성만 지운다. 그 밖엔 non-strict 에선 false, strict(모듈·클래스)에선 예외다1

const obj = { name: 'Alice', age: 25 }
delete obj.age // true

// 존재하지 않는 속성 삭제도 true
delete obj.city // true

// configurable: false는 삭제 불가
const locked = Object.defineProperty({}, 'readOnly', {
  value: 'I cannot be deleted',
  configurable: false,
})
delete locked.readOnly // false (strict 에선 TypeError)

// 변수 삭제 불가 (strict 에선 SyntaxError)
let globalVar = 'exists'
delete globalVar // false

// 배열 요소 삭제 (hole 생성)
let arr = [1, 2, 3]
delete arr[1] // [1, <empty>, 3]
  • 설정 가능한(configurable) 속성에만 사용
  • 배열은 splice 권장

Footnotes

  1. delete - MDN: “true for all cases except when the property is an own non-configurable property, in which case false is returned in non-strict mode.” · “delete variable will throw a SyntaxError in strict mode, and will have no effect in non-strict mode.” ↩

#343
  • 문제: 스프레드시트 데이터가 커서 내용 확인이 어려움.
  • 해결: 구글 드라이브에 스프레드시트 데이터를 CSV 파일로 업로드하여 확인.
  • 셀에 쉼표·줄바꿈·큰따옴표가 있으면 join(',') 만으로는 열이 밀린다 — 셀마다 큰따옴표로 감싸고 안의 " 는 "" 로 겹친다.1
/**
 * 현재 활성 스프레드시트의 첫 번째 시트 데이터를 CSV 파일로 저장하고,
 * 구글 드라이브에 업로드합니다.
 * 생성된 파일의 URL은 콘솔에 로그로 출력됩니다.
 */
function saveSpreadsheetDataAsCSV() {
  /** 현재 활성 스프레드시트 */
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]
  /** 시트의 모든 데이터의 2차원 배열 */
  const data = sheet.getDataRange().getValues()
  /** CSV 데이터 */
  const csv = data
    .map((row) => row.map((v) => '"' + String(v).replace(/"/g, '""') + '"').join(','))
    .join('\n')
  /** 파일명 */
  const fileName = 'spreadsheet_data.csv'
  /** 구글 드라이브에 업로드 완료된 CSV 파일 */
  const file = DriveApp.createFile(fileName, csv, MimeType.CSV)

  console.log('파일이 생성되었습니다: ' + file.getUrl())
}

Footnotes

  1. RFC 4180 §2: “Fields containing line breaks (CRLF), double quotes, and commas should be enclosed in double-quotes.” / “If double-quotes are used to enclose fields, then a double-quote appearing inside a field must be escaped by preceding it with another double quote.” ↩

#342

baseColor 위에 흰색을 몇 % 로 덮어야 targetColor 가 되는지 역산한다 — 합성식 결과 = 흰색·α + base·(1−α) 를 α 에 대해 푼 것1

class ColorManipulator {
  private baseColor: string
  private targetColor: string

  constructor(baseColor: string, targetColor: string) {
    this.baseColor = baseColor
    this.targetColor = targetColor
  }

  private hexToRgb(hex: string) {
    const bigint = parseInt(hex.slice(1), 16)

    return {
      r: (bigint >> 16) & 255,
      g: (bigint >> 8) & 255,
      b: bigint & 255,
    }
  }

  public calculateOpacity() {
    const target = this.hexToRgb(this.targetColor)
    const baseRG = this.hexToRgb(this.baseColor)
    const opacities = [
      (target.r - baseRG.r) / (255 - baseRG.r),
      (target.g - baseRG.g) / (255 - baseRG.g),
      (target.b - baseRG.b) / (255 - baseRG.b),
    ]
    const averageOpacity =
      opacities.reduce((sum, value) => sum + value, 0) / opacities.length

    return averageOpacity
  }

  public getCssRGBA() {
    const opacity = this.calculateOpacity()

    return `rgba(255, 255, 255, ${opacity.toFixed(2)})` // 덮는 색은 흰색
  }
}

const manipulator = new ColorManipulator('#000000', '#D1D7DE')
const opacity = manipulator.calculateOpacity()
const cssRGBA = manipulator.getCssRGBA() // rgba(255, 255, 255, 0.84)

채널별 α 가 다르면(여기선 0.82·0.84·0.87) 평균 하나로는 정확히 못 맞춘다 — 검정 위 흰색 0.84 는 rgb(214, 214, 214), 목표는 rgb(209, 215, 222).


Footnotes

  1. Compositing and Blending Level 1 §5.1 Simple alpha compositing - W3C: “co = Cs x αs + Cb x αb x (1 - αs)” — 바탕이 불투명(αb = 1)이면 Cs·α + Cb·(1−α). ↩

#341

구글 스프레드시트에서 중복 값에 스타일 주기 — 서식 > 조건부 서식 > 맞춤 수식에 넣는다. 수식은 범위(A열)의 첫 행 기준으로 쓴다.1

=COUNTIF(A:A, A1)>1

Footnotes

  1. Use conditional formatting rules in Google Sheets: “Write the rule for the first row. In this case the rule would be, “=COUNTIF($A$1:$A$100,A1)>1.”“ ↩

#340
33 중 13페이지