07. Implement Schemas
เราจะมาทำ Schema กันก่อน
เพื่อกำหนดก่อนว่าใน App ของเรา Data ที่ไหลไปไหลมาจะหน้าตาแบบไหน คือเป็นหน้าตาของ data ของเราที่ business เราต้องการใช้งาน
และเราจะยึด Schema นี้เป็นหลัง ส่วนอื่นๆเช่น Repositories หรือ Controllers จะต้องทำตามสิ่งที่ Business ต้องการ
ไม่ว่าเราจะใช้ Database อะไร ORM ยี่ห้อไหน เราจะต้องแปลงข้อมูลให้อยู่ในรูปแบบที่เราอยากได้ นั่นก็คือ Schema นี้นี่แหละ
ในที่นี้จะใช้ Effect Schema ถ้าใครจะใช้ Zod หรือ Runtime validation ตัวอื่นก็ตามสะดวก
ข้อดีของ Effect Schema คือ
- เราสามารถแปลงกลับไปกลับมาระหว่าง Schema กับ Typescript Type ธรรมดาได้
- เราสามารถแปลง Schema ให้เป็น JsonSchema ซึ่งเอาไว้ใช้ทำ OpenAPI Doc ได้เลย
- ทำ Type safe parsing ได้ด้วย คือมันจะต่างจาก Zod ตรงที่ Zod จะ parse data จาก unknown ให้มี object หน้าตาเหมือน Schema แต่ Effect Schema ก็ทำแบบนั้นได้เหมือนกัน และทำได้มากกว่าด้วย ตรงที่สามารถเลือกได้ว่าจะ parse จาก unknown หรือ parse จาก typescript type
- Effect Schema จะเป็น Immutable โดย Default คือ object ที่ได้จากการ parsed แล้วจะแก้ไขไม่ได้ โดย Effect จะใช้เทคนิดการใส่
readonly
เข้าไปด้านหน้า properties ทุกตัว ถ้าเราอยากให้ Schema ตัวไหนถูกแก้ไขได้ ต้องกำหนดเองผ่านSchema.mutable()
Files
โครงสร้าง folders ของเราจะเป็นแบบนี้
Branded Id for each Schemas
เรามาเริ่มทำ Id กันก่อน
ทำไมถึงต้องทำ Id แยก
เนื่องจากว่าใน Database เราเก็บ Id เป็นตัวเลขที่เป็น running number
และใน app ของเรา Id ก็เป็น Number เช่นกัน
แต่ เราใช้ตัวเลขแทน Id ในแต่ละ Row ใน Table ของเรา ซึ่งเราไม่สามารถเอา number อะไรก็ได้มาใช้เป็น Id ฉนั้นเพื่อให้ App เรา Safe มากที่สุด ก็เลยใช้ Branded type
Branded type เป็นการกำหนดให้ Primitive type ปกติ กลายเป็น type พิเศษที่ไม่สามารถเอา Type อื่นมาแทนได้ compiler จะบอกเราเลยว่าผิด type แต่ตัวมันยังทำงานแบบ Primitive type ได้เหมือนเดิมนะ เหมือนเป็น Primitive type นี่แหละ
ยกตัวอย่างเช่นเราให้ id มี type เป็น number ถ้าเราไม่ใช้ branded type เราจะใส่ number อะไรมาก็ได้ compiler มันก็บอกว่า ok เป็น number ตรงกันเรายอมรับ ถ้า dev เผลอเอา age มาใส่ที่ id compiler ก็ยัง ok กับสิ่งนี้
แต่ถ้าใช้ branded type ที่เป็น number เวลาเราใส่ number ธรรมดาเข้ามา compiler มันจะไม่ยอม ซึ่งช่วยลดความผิดพลาดของเราไปได้
schema/branded.ts
จากตัวอย่างด้านบนเราจะสร้าง Branded type จาก Effect schema เลย เพราะเราจะใช้ Effect schema เป็นหลักอยู่แล้ว
แต่ถ้าใครไม่ได้ใช้ Effect Schema ก็ยังสามารถสร้าง Branded type จาก Effect ได้อยู่นะ ไปดูเพิ่มเติมได้ที่ Effect Docs
หรือถ้าใครไม่อยากใช้ Effect ก็สามารถสร้าง Branded type มาใช้เองได้ไปดูเพิ่มเติมได้ที่ youtube
จากตัวอย่างด้านบนจะเห็นว่าใส่ .annotations()
เข้าไปด้วย
เนื่องจากว่าเราจะใช้ Effect Schema ไปสร้าง OpenAPI Docs ด้วยเลย ซึ่งมันเป็นความสามารถของ Effect Schema อยู่แล้ว ที่สร้าง JsonSchema ออกมาได้จาก Effect Schema
แต่เนื่องจากว่า Branded type ไม่ได้เป็น type มาตรฐานสำหรับ JsonSchema เราก็เลยต้องระบุเองว่ามันเป็น type อะไร ตรงนี้เดี๋ยวมีอธิบายเพิ่มเติมอีกสำหรับ .annotations()
กับ JsonSchema
และจะเห็นว่ามี EmployeeIdFromString
ด้วย
อันนี้เอาไว้แปลง id จากต้นทางที่อยู่ในรูปของ string ให้กลายเป็น number ก่อน แล้วค่อยแปลงเป็น Branded type อีกทีนึง เราก็เลยจะใช้
S.transform()
ส่วน decode
กับ encode
ก็คือตอนที่เราแปลงกลับไปกลับมาให้มันทำอะไรบ้างเพื่อให้ได้ type ที่เราต้องการ
General Schema that can reuse in other Schemas
schema/general.ts
import * as import S
S from "effect/Schema"
export const const TimeStampSchema: S.Struct<{ createdAt: S.SchemaClass<Date, Date, never>; deletedAt: S.NullOr<typeof S.DateFromSelf>; updatedAt: S.SchemaClass<Date, Date, never>;}>
TimeStampSchema = import S
S.function Struct<{ createdAt: S.SchemaClass<Date, Date, never>; deletedAt: S.NullOr<typeof S.DateFromSelf>; updatedAt: S.SchemaClass<Date, Date, never>;}>(fields: { createdAt: S.SchemaClass<...>; deletedAt: S.NullOr<...>; updatedAt: S.SchemaClass<...>;}): S.Struct<...> (+1 overload)
Struct({ createdAt: S.SchemaClass<Date, Date, never>
createdAt: import S
S.class DateFromSelf
Describes a schema that accommodates potentially invalid Date
instances,
such as new Date("Invalid Date")
, without rejection.
DateFromSelf.Annotable<SchemaClass<Date, Date, never>, Date, Date, never>.annotations(annotations: S.Annotations.Schema<Date, readonly []>): S.SchemaClass<Date, Date, never>
Merges a set of new annotations with existing ones, potentially overwriting
any duplicates.
annotations({ Annotations.Schema<Date, readonly []>.jsonSchema?: object
jsonSchema: { example: string
example: "2021-01-01T00:00:00.000Z", title: string
title: "createdAt", type: string
type: "string" } }), deletedAt: S.NullOr<typeof S.DateFromSelf>
deletedAt: import S
S.class DateFromSelf
Describes a schema that accommodates potentially invalid Date
instances,
such as new Date("Invalid Date")
, without rejection.
DateFromSelf.Pipeable.pipe<typeof S.DateFromSelf, S.NullOr<typeof S.DateFromSelf>>(this: typeof S.DateFromSelf, ab: (_: typeof S.DateFromSelf) => S.NullOr<typeof S.DateFromSelf>): S.NullOr<typeof S.DateFromSelf> (+21 overloads)
pipe(import S
S.const NullOr: <S extends S.Schema.All>(self: S) => S.NullOr<S>
NullOr).NullOr<typeof DateFromSelf>.annotations(annotations: S.Annotations.Schema<Date | null, readonly []>): S.NullOr<typeof S.DateFromSelf>
Merges a set of new annotations with existing ones, potentially overwriting
any duplicates.
annotations({ Annotations.Schema<A, TypeParameters extends ReadonlyArray<any> = readonly []>.jsonSchema?: object
jsonSchema: { example: string
example: "2021-01-01T00:00:00.000Z", title: string
title: "deletedAt", type: string
type: "string" } }), updatedAt: S.SchemaClass<Date, Date, never>
updatedAt: import S
S.class DateFromSelf
Describes a schema that accommodates potentially invalid Date
instances,
such as new Date("Invalid Date")
, without rejection.
DateFromSelf.Annotable<SchemaClass<Date, Date, never>, Date, Date, never>.annotations(annotations: S.Annotations.Schema<Date, readonly []>): S.SchemaClass<Date, Date, never>
Merges a set of new annotations with existing ones, potentially overwriting
any duplicates.
annotations({ Annotations.Schema<Date, readonly []>.jsonSchema?: object
jsonSchema: { example: string
example: "2021-01-01T00:00:00.000Z", title: string
title: "updatedAt", type: string
type: "string" } }),})
จาก code ด้านบนก็จะมี
- createdAt
- deletedAt
- updatedAt
จะเห็นว่าใช้ S.DateFromSelf
คือเราจะแปลงจาก Date มาเป็น Date
จริงๆเราจะไม่ได้แปลงจาก Date มาเป็น Date อย่างเดียว
คือ Date object เนี่ย มันจะมี invalid date ด้วยเช่น new Date("invalid date")
ซึ่งเราจะได้ Date object มา แต่มันดัน invalid
เราเอาไปใช้งานต่อไม่ได้ ตรงนี้แหละ S.DateFromSelf
มันจะคอยเช็คให้เรา ถ้าเป็น invalid date มันจะ parse ไม่ผ่าน
อีกจุดนึงคือ .annotations()
อีกแล้ว เหตุผลเดียวกันกับเรื่องของ Branded type เลย
และจะเห็นว่าเราใส่ example ได้ด้วย ใส่ title ได้ด้วย ทั้งหมดนี้จะได้เห็นตอนเราใช้ OpenAPI Docs มันจะไปโผล่ในนั้น
Schema for Overtime
schema/overtime.ts
import * as S from "effect/Schema"import * as Branded from "./branded.js"import * as GeneralSchema from "./general.js"
export const Schema = S.Struct({ date: S.Union(S.Date, S.DateFromSelf).annotations({ jsonSchema: { description: "Date or ISODate", example: "2025-01-01", title: "Date or ISODate", type: "string", }, }), employeeId: Branded.EmployeeId, hoursWorked: S.Number, id: Branded.OvertimeId, reason: S.String, ...GeneralSchema.TimeStampSchema.fields, _tag: S.Literal("Overtime").pipe(S.optional, S.withDefaults({ constructor: () => "Overtime" as const, decoding: () => "Overtime" as const, })),})
export type Overtime = S.Schema.Type<typeof Schema>export type OvertimeEncoded = S.Schema.Encoded<typeof Schema>
export const SchemaArray = S.Array(Schema)export type OvertimeArray = S.Schema.Type<typeof SchemaArray>export type OvertimeArrayEncoded = S.Schema.Encoded<typeof SchemaArray>
export const CreateSchema = Schema.omit("_tag", "id", "createdAt", "updatedAt", "deletedAt")export type CreateOvertime = S.Schema.Type<typeof CreateSchema>
export const UpdateSchema = Schema.omit("_tag", "createdAt", "updatedAt", "deletedAt")export type UpdateOvertime = S.Schema.Type<typeof UpdateSchema>
จาก code ด้านบน เราทำ Schema ของ Overtime ก่อนตามโจทย์ของเรา
มีการใส่ property _tag
เข้าไปด้วย
ตรงนี้ผมมักจะติด tag ให้กับ object ด้วย เราอาจจะได้เอาไปใช้กับ pattern matching ในภายหลัง หรืออย่างน้อยๆก็มีจุดที่เอาไว้เช็คว่าเป็น object ของอะไรตอน runtime
โดยถ้าเรา parse ผ่าน ตัว Effect Schema ก็จะติด tag ให้เราเองเลย
จะเห็นว่าเราสร้าง type สำหรับ Overtime
ผ่าน S.Schema.Type<>
เราจะได้ Type ที่ได้จากการ parse โดยใช้ Schema นี้
ส่วนอีกอัน type OvertimeEncoded
สร้างจาก S.Schema.Encoded<>
คือ type ที่ได้จากการเอา Schema type ทำการ parse
กลับมาเป็น typescript type แบบธรรมดา
เรามีการสร้าง Schema สำหรับ Array ของ Overtime ด้วย
และยังสร้าง Schema สำหรับการ Create Overtime ด้วย ตอนที่เราจะส่ง data ที่ได้ไปให้กับ Database ไม่ต้องการ _tag
, id
, createdAt
, updatedAt
และ deletedAt
เพราะ fields เหล่านี้มันจะถูกสร้างให้อัตโนมัติจาก database
เราสามารถใช้ .omit()
มาช่วย เพื่อบอกว่าให้ตัด fields เหล่านั้นออกไปซะ
แล้วก็เหมือนกันกับ Schema สำหรับการ Update Overtime ตรงนี้เราต้องการ id ก็เลยลบออกไปเท่าที่เห็น
Create Schema for Employee
schema/employee.ts
import * as S from "effect/Schema"import * as Branded from "./branded.js"import * as GeneralSchema from "./general.js"
export const Role = S.Literal("Junior_Developer", "Senior_Developer", "Lead", "C_Level")
export const Department = S.Literal("IT", "Accounting", "HR", "Manager")
export const Schema = S.Struct({ department: Department, id: Branded.EmployeeId, name: S.String.annotations({ jsonSchema: { example: "John Doe", title: "name", type: "string" } }), role: Role, ...GeneralSchema.TimeStampSchema.fields, _tag: S.Literal("Employee").pipe(S.optional, S.withDefaults({ constructor: () => "Employee" as const, decoding: () => "Employee" as const, })),})
export type Employee = S.Schema.Type<typeof Schema>export type EmployeeEncoded = S.Schema.Encoded<typeof Schema>
export const SchemaArray = S.Array(Schema)export type EmployeeArray = S.Schema.Type<typeof SchemaArray>export type EmployeeArrayEncoded = S.Schema.Encoded<typeof SchemaArray>
export const CreateSchema = Schema.pick("name", "role", "department")export type CreateEmployee = S.Schema.Type<typeof CreateSchema>export type CreateEmployeeEncoded = S.Schema.Encoded<typeof CreateSchema>
export const UpdateSchema = Schema.omit("_tag", "createdAt", "updatedAt", "deletedAt")export type UpdateEmployee = S.Schema.Type<typeof UpdateSchema>export type UpdateEmployeeEncoded = S.Schema.Encoded<typeof UpdateSchema>
Employee with relation schema
จาก requirements ที่ได้
employee สามารถทำ overtime ได้ ฉนั้น Employee ก็จะมี relation ไปที่ Overtime เราก็จะมาสร้าง schema เพิ่มระหว่างกัน เป็น one to many
และก็มี Array ด้วยเช่นกัน
schema/employeeWithRelations.ts
import * as S from "effect/Schema"
import * as EmployeeSchema from "./employee.js"import * as OvertimeSchema from "./overtime.js"
export const Schema = S.Struct({ ...EmployeeSchema.Schema.fields, overtimes: S.Array(OvertimeSchema.Schema),
})
export type EmployeeWithRelations = S.Schema.Type<typeof Schema>export type EmployeeWithRelationsEncoded = S.Schema.Encoded<typeof Schema>
export const SchemaArray = S.Array(Schema)export type EmployeeWithRelationsArray = S.Schema.Type<typeof SchemaArray>export type EmployeeWithRelationsArrayEncoded = S.Schema.Encoded<typeof SchemaArray>
Create Overtime with relation schema
ส่วนของ Overtime Schema ก็ทำเหมือนกัน ในกรณีที่เราอยากดูว่า overtime นี้เป็นของใคร ซึ่ง 1 overtime มีได้ 1 employee
schema/overtimeWithRelations.ts
import * as S from "effect/Schema"import * as EmployeeSchema from "./employee.js"import * as OvertimeSchema from "./overtime.js"
export const Schema = S.Struct({ ...OvertimeSchema.Schema.fields, employee: EmployeeSchema.Schema,})
export type OvertimeWithRelations = S.Schema.Type<typeof Schema>export type OvertimeWithRelationsEncoded = S.Schema.Encoded<typeof Schema>
export const SchemaArray = S.Array(Schema)export type OvertimeWithRelationsArray = S.Schema.Type<typeof SchemaArray>export type OvertimeWithRelationsArrayEncoded = S.Schema.Encoded<typeof SchemaArray>
Helper functions work with Schemas
เรามี schema หลายอันเลย แล้วเราจะใช้มัน parse กลับไปกลับมา โดยใช้
- function
S.decodeUnknownSync()
เพื่อแปลงจาก object ธรรมดาให้เป็น schema - และกลับกับ
S.encodeSync()
เพื่อแปลง schema ให้เป็น object ธรรมดา
ซึ่งส่วนตัวผมคิดว่าชื่อมัน งง ก็เลยเปลี่ยนชื่อมันด้วยการเพิ่ม Helper functions แบบนี้
schema/helpers.ts
import S from "effect/Schema"
export const fromObjectToSchema = S.decodeUnknownSyncexport const fromSchemaToObject = S.encodeSync
Exports tree shakeable files
ทีนี้เราก็ export ทุกอย่างออกไปเพื่อการใช้งานที่ง่ายมากขึ้น
schema/index.ts
export * as Branded from "./branded.js"export * as EmployeeSchema from "./employee.js"export * as EmployeeWithRelationsSchema from "./employeeWithRelations.js"export * as GeneralSchema from "./general.js"export * as Helpers from "./helpers.js"export * as OvertimeSchema from "./overtime.js"export * as OvertimeWithRelationsSchema from "./overtimeWithRelations.js"