openlang

설정 객체, 타입 표기냐 satisfies냐

설정 객체는 요즘 거의 const config: Config = {...} 대신 {...} satisfies Config로 써요. 타입 표기를 붙이면 'dark'라고 써 놨는데도 config.mode는 'light' | 'dark'로 읽혀요. satisfies를 쓰면 객체는 Config에 맞는지 검사받고, 변수는 쓴 그대로의 리터럴 타입을 유지해요. 그래서 config.mode는 'dark'이고, 나중에 'light'와 비교하면 죽은 분기가 아니라 타입 오류가 나요.

리터럴에서 속성 이름을 잘못 쓰면 두 방식 모두 잡아 줘요. 4.9 릴리스 노트에도 Record<Colors, string | RGB>에 대고 bleu 키를 잡아내는 예가 있어요.

저를 속인 건 satisfies Record<string, Route>로 쓴 라우트 테이블이었어요. 거기서는 철자가 틀린 라우트 이름이 초과 속성이 아니에요. 모든 문자열 키가 허용되니까 검사는 통과하고, 오타는 아무도 링크하지 않는 라우트가 돼 버려요. 타입 표기 쪽에도 같은 구멍이 있어요. satisfies는 실제보다 더 검사받았다는 기분을 줬을 뿐이에요.

키가 정해져 있으면 이제는 Record<RouteName, Route>처럼 유니온을 직접 적고, 낯선 키가 끼어들면 객체가 실패하게 둬요.

이어지는 대화 5

서로의 근거를 따라
  1. 고유 링크

    선택 속성에서도 차이가 하나 더 있습니다. 타입 표기를 쓰면 리터럴에서 빠뜨린 timeout?: number도 number | undefined로 읽히고, 읽는 쪽마다 undefined가 무슨 뜻인지 정해야 합니다. satisfies를 쓰면 그 속성은 추론된 타입에 아예 없어서 config.timeout은 TS2339, 존재하지 않는 속성 오류가 됩니다.

    더 날카로운 답이고, 다른 답이기도 합니다. 나중에 리터럴에 timeout을 넣으면 거부되던 읽기 코드가 전부 컴파일되기 시작합니다. 호출하는 쪽이 값이 없는 경우를 처리해야 하는지, 아예 보지 말아야 하는지로 고르면 됩니다.

  2. 고유 링크

    내보내는 객체라면 저는 반대로 가겠어요. satisfies 객체는 리터럴 타입까지 같이 내보내니까, config를 가져다 쓰는 모듈은 전부 그 안의 값에 기대게 돼요. 'dark'를 'light'로 바꾸면 세 파일 떨어진 곳의 비교가 컴파일되지 않아요. 그 코드는 타입이 아니라 값에 기대고 있었던 건데, 아무도 그렇게 하자고 정한 적이 없어요. 애플리케이션 설정이라면 저는 여전히 타입 표기가 나아요. Config는 코드가 기대도 되는 것만 말해요. 그뿐이에요.

  3. 고유 링크

    루트에 쓴 리터럴 얘기는 실제보다 단순하게 들려요. satisfies가 쓴 리터럴을 전부 유지하는 건 아니에요.

    type Config = { mode: 'light' | 'dark'; port: number };
    
    const config = { mode: 'dark', port: 8080 } satisfies Config;
    
    const m: 'dark' = config.mode;  // ok
    const p: 8080 = config.port;    // error, port is number

    port는 Config가 문맥 타입으로 number를 주기 때문에 number로 넓어져요. mode가 'dark'로 남는 건 Config가 그걸 문자열 리터럴 유니온으로 선언했기 때문일 뿐이에요. 그러니까 유지되는 건 더 구체적인 추론 타입이고, 얼마나 구체적인지는 Config에 달려 있어요. 가져다 쓰는 모듈이 값에 기대게 된다는 걱정도 mode에는 해당되고 port에는 대체로 해당되지 않아요.

  4. 고유 링크

    그러니까 사용자가 설정 화면에서 다크 테마를 고르면, 그 선택이 JSON 파일에 써져요. 다음에 앱을 켜면 그 파일을 다시 읽는데, satisfies는 거기 근처에도 간 적이 없어요(소스에 있는 리터럴만 봤으니까요). 리터럴 config는 'dark'라고 하는데 저장된 파일에는 'Dark'가 있을 수도, 아무것도 없을 수도 있어요. 다들 satisfies 객체는 기본값으로 두고, 저장된 파일은 런타임 검사로 Config에 맞춰 파싱하나요? 아니면 둘이 어긋나지 않게 하는 방법이 따로 있는데 제가 모르는 건가요?

  5. 고유 링크

    직접 해 보고 제 질문에 제가 답해요. satisfies 리터럴은 defaults가 됐고, 저장된 파일은 작은 parseSettings(raw: unknown): Partial<Config>를 거치고, 앱은 const config: Config = { ...defaults, ...saved }로 돌아가요. 합친 값에 타입 표기를 붙이니까 selim70님이 걱정한 부분도 해결돼요. 가져다 쓰는 모듈은 Config만 보거든요.

    발목을 잡은 건 파서였어요. 처음 버전은 아는 키를 전부 복사하고, 값이 이상하면 undefined를 넣었어요. 그런데 명시적인 undefined를 스프레드하면 기본값이 지워지고, 타입은 아무 말도 안 했어요(그 프로젝트엔 exactOptionalPropertyTypes가 꺼져 있었거든요). 그래서 손으로 "Dark"라고 적은 사용자는 다크도 라이트도 아닌, 아무것도 선택 안 된 테마 선택 화면을 보게 됐어요. 지금은 파서가 잘못된 키를 아예 빼고, 설정 화면에 저장된 값 하나를 무시했다는 짧은 안내를 띄워요.

같은 질문의 다른 갈래

  1. 측정 단위를 값싼 ID로 쓰기

    정수 ID에 단일 케이스 유니온 대신 측정 단위를 써 보고 있는데, 더 퍼지기 전에 다른 의견을 듣고 싶어요. CustomerId of int는 [<Struct>]를 붙이지 않으면 할당이 생기고, 쓸 때마다 생성자나 매치를…

    답글 1fsharp
  2. Java에서 옮긴 헬퍼가 Groovy에서 다른 오버로드를 부른다

    Java 헬퍼 클래스를 빌드 스크립트 쪽 Groovy로 옮겼더니 같은 호출이 다른 메서드로 갔습니다. 순서대로 정리하면 이렇습니다. Java에서는 x의 선언 타입이 Object이므로 컴파일 시점에 show(Object)가 정…

  3. 빈 셀은 0과도 같고 빈 문자열과도 같다

    물려받은 매크로가 수량이 0인 주문 행을 표시하는데, 빈 행까지 전부 표시하더라고요. 검사 코드는 If ws.Cells(r, 4).Value = 0 Then이었어요. 빈 셀은 Empty가 든 Variant를 돌려주고, Emp…

    답글 7vba