Logo

Zod로 입출력 간 데이터 변환하기

지난 포스팅에서 Zod로 스키마를 정의하는 다양한 방법에 대해서 알아보았는데요.

Zod의 부가적인 기능이지만 알아두면 굉장히 유용한 입출력 간 데이터 변환에 대해서 알아보겠습니다.

내장 트랜스포머

Zod는 입출력 간 문자열 변환을 돕기 위해서 트랜스포머(transformer)를 내장하고 있는데요. 대표적으로 .trim(), .toLowerCase(), .toUpperCase()를 들 수 있습니다.

그럼 이 3가지 내장 트랜스포머를 모두 사용해서 스키마를 하나 정의한 후에 데이터 변환을 해보겠습니다.

import { z } from "zod";

// 스키마 정의
const Transformers = z.object({
  trimmed: z.string().trim(),
  lowerCased: z.string().toLowerCase(),
  upperCased: z.string().toUpperCase(),
});

// 데이터 변환
const output = Transformers.parse({
  trimmed: " Hello, Zod! ",
  lowerCased: "Hello, Zod!",
  upperCased: "Hello, Zod!",
});

결과를 출력해보면 입력된 문자열이 좌우 공백 제거나 대소문자 변환이 되어서 출력되는 것을 볼 수 있습니다.

console.log(output);
콘솔
{
  trimmed: 'Hello, Zod!',
  lowerCased: 'hello, zod!',
  upperCased: 'HELLO, ZOD!'
}

트랜스포머 구현

Zod는 우리가 직접 구현한 트랜스포머도 사용할 수 있도록 .transform()라는 API도 제공하고 있는데요.

예를 들어, 입력은 숫자형이나 문자형으로 모두 받을 수는 있지만 출력할 때는 숫자형인 경우 문자형으로 변환해주는 스키마를 작성해보겠습니다.

import { z } from "zod";

// 스키마 정의
const ID = z
  .string()
  .or(z.number())
  .transform((id) => (typeof id === "number" ? String(id) : id));

// 데이터 변환
const id = ID.parse(1);

스키마 입력으로 숫자를 넘겨보면 문자형으로 출력이 나오는 것을 불 수 있습니다.

console.log(typeof id, id);
콘솔
string 1

그런데 이렇게 입력 자료형과 출력 자료형이 상이한 스키마로 부터 타입을 뽑아내면 어떻게 될지 궁금하지 않으신가요?

Zod를 사용하여 하나의 스키마로 유효성 검증과 타입 선언을 한 번에 해결하는 방법에 대해서는 별도 포스팅에서 자세히 다루고 있습니다.

z.infer을 사용해서 스키마로부터 타입을 추출해보면 출력 자료형 기준으로 타입 추론이 된다는 것을 알 수 있는데요.

type ID = z.infer<typeof ID>;
//   ^? type ID = string

만약에 입력 자료형 기준으로 타입을 뽑아내고 싶다면 대신에 z.input을 사용하면 됩니다. 마찬가지로 z.output도 있는데 z.infer과 동일하게 작동합니다.

type Input = z.input<typeof ID>;
//   ^? type Input = string | number
type Output = z.output<typeof ID>;
//   ^? type ID = string

같은 스키마로부터 입출력 타입을 모두 뽑아내면 다음과 같이 함수를 타이핑할 때 매우 유용하게 사용할 수 있습니다.

function processID(input: Input): Output {
  const output = ID.parse(input);
  // ID 처리 로직
  return output;
}

복잡한 입출력간 데이터 변환

Zod의 .transform() API를 활용하여 하나의 객체 내에서 여러 다른 속성이 서로에게 영향을 주는 좀 더 복잡한 변환도 구현할 수 있는데요.

예를 들어, firstNamelastName 속성은 필수 입력으로 middleName 속성은 선택 입력으로 받고, 이를 토대로 fullName 속성은 트랜스포머를 통해서 출력에 추가되도록 스키마를 정의해보겠습니다.

import { z } from "zod";

const User = z
  .object({
    firstName: z.string(),
    middleName: z.string().optional(),
    lastName: z.string(),
  })
  .transform((user) => ({
    ...user,
    fullName: user.middleName
      ? `${user.firstName} ${user.middleName} ${user.lastName}`
      : `${user.firstName} ${user.lastName}`,
  }));

이제 한 번은 middleName을 생략하고, 한 번은 middleName을 포함시켜 변환을 해보면

console.log(User.parse({ firstName: "John", lastName: "Doe" }));
console.log(
  User.parse({ firstName: "John", middleName: "K.", lastName: "Doe" })
);

두 가지 경우 모두 정의한 변환 규칙에 따라서 결과에 fullName 속성이 추가되는 것을 볼 수 있습니다.

콘솔
{ firstName: 'John', lastName: 'Doe', fullName: 'John Doe' }
{
  firstName: 'John',
  middleName: 'K.',
  lastName: 'Doe',
  fullName: 'John K. Doe'
}

스키마로부터 타입을 추론해보면 결과 타입에만 fullName 속성이 포함되어 있는 것을 볼 수 있습니다.

type Input = z.input<typeof User>;
//   ^? type Input = { firstName: string; lastName: string; middleName?: string | undefined; }
type Output = z.output<typeof User>;
//   ^? type Output = { fullName: string; firstName: string; lastName: string; middleName?: string | undefined; }

마치면서

지금까지 Zod로 유효성 검증 뿐만 아니라 입출력 간 데이터 변환도 가능하다는 것을 배웠습니다. 스키마 수준에서 이렇게 간편하게 데이터 변환을 할 수 있다는 점이 참 매력적이지 않나요? 🥰

Zod 관련 포스팅은 Zod 태그를 통해서 쉽게 만나보세요!