ค้นหาเว็บไซต์

javascript - TS: สืบทอดชนิดค่าคงที่ตามวิธีที่ต่อกัน


เหลือเพียงสิ่งเดียวสำหรับฉันในการปลดล็อกการไหลและเผยแพร่เวอร์ชันทดลองแรกของไลบรารีการตรวจสอบฟอร์มของฉัน

ฉันมีโค้ดดังต่อไปนี้ (แน่นอนว่าฉันละเว้นหลายสิ่งหลายอย่างเพื่อไม่ให้มันใหญ่เกินไป)

interface Validation {
  name: string
  message: string
  params?: Record<string, any>
  test: (value: any, params?: any) => boolean
}

class MixedSchema<T> {
  type!: T
  validations: Validation[] = []

  required(message?: string) {
    this.validations.push({
      name: 'required',
      message: message,
      test: (value: any) => {
        return value === '' ? false : true
      },
    })

    return this
  }

  oneOf(arrayOfValues: any[], message?: string) {
    type Params = { arrayOfValues: any[] }

    this.validations.push({
      name: 'oneOf',
      params: { arrayOfValues },
      message: message,
      test: (value: any, params: Params) => {
        return params.arrayOfValues.includes(value)
      },
    })

    return this
  }
}

class StringSchema extends MixedSchema<string> {
  email() {
    // this.validations.push({ ... })
    return this
  }

  maxWords(maxWords: number, message?: string) {
    // this.validations.push({ ... })
    return this
  }
}

class NumberSchema extends MixedSchema<number> {
  min(min: number, message?: string) {
    // this.validations.push({ ... })
    return this
  }

  round(type: 'toUp' | 'toDown' | 'closer') {
    // this.validations.push({ ... })
    return this
  }
}

const schema = {
  string() {
    return new StringSchema()
  },
  number() {
    return new NumberSchema()
  },
  // array, file, etc..
}

const form = {
  email: schema.string().required(),
  age: schema.number().min(18),
  gender: schema.string().oneOf(['male', 'female']).required(),
  color: schema.string().oneOf(['red', 'blue', 'green']),
}

type InferType<T> = {
  [P in keyof T]: T[P] extends MixedSchema<infer TS> ? TS : never
}

type Form = InferType<typeof form>

ดังนั้นฉันจึงได้ผลลัพธ์ดังต่อไปนี้

แต่ฉันต้องการให้ได้การพิมพ์ที่สมจริงที่สุดเท่าที่จะเป็นไปได้ หมายความว่า คล้ายกับแบบแผนที่กำหนดไว้สำหรับ form ตัวอย่าง

interface HowNeedsToBe {
  email: string
  age: number | undefined
  gender: 'male' | 'female'
  color: 'red' | 'blue' | 'green' | undefined
}

ฉันเชื่อว่าเหตุผลคือประมาณว่า ถ้าไม่มี required ก็ใส่ undefined และถ้ามี oneOf ก็แทนที่อาร์กิวเมนต์ด้วย T ของ MixedSchema<T> แต่ฉันไม่รู้ว่าจะส่งสิ่งนี้กลับไปยัง MixedSchema<T> ได้อย่างไร จริงๆ แล้วฉันคิดว่าเหตุผลนี้มันยุ่งเหยิง

ฉันได้ทำการค้นคว้าเกี่ยวกับ map และ generics ใน TypeScript แล้ว แต่ฉันต้องยอมรับว่าเมื่อถึงเวลานำไปใช้จริง ไม่มีอะไรดีออกมาเลย

นี่คือสนามเด็กเล่น TS ถ้าคุณต้องการลอง

คำตอบ #1

ในเชิงแนวคิด คุณต้องการให้เมธอด required() และ oneOf() ลดขอบเขตของ T; ซึ่งการให้ประเภทกับมันคงไม่ยากนัก (แม้ว่าคุณจะต้องใช้การยืนยันประเภทเพื่อหลีกเลี่ยงข้อผิดพลาดของคอมไพเลอร์ เนื่องจากคอมไพเลอร์ไม่สามารถตรวจสอบได้ว่าคุณได้ทำการลดขอบเขตตามที่ต้องการจริง ๆ) ดังนั้น required() เมื่อถูกเรียกบน MixedSchema<T> ควรคืนค่า MixedSchema<Exclude<T, undefined>> (ใช้ยูทิลิตี้ประเภท Exclude เพื่อลบ undefined ออกจากสมาชิกยูเนียนใด ๆ ของ T) และ oneOf() ควรเป็นเจนเนอริกในประเภทขององค์ประกอบ U ของสมาชิกของ arrayOfValues และควรคืนค่า MixedSchema<U | Extract<undefined, T>> (ใช้ยูทิลิตี้ประเภท Extract เพื่อเก็บ undefined หาก T อาจเป็น undefined)

นี่คือลักษณะที่มันอาจปรากฏ (การใช้งานถูกละไว้เพื่อความกระชับ):

declare class MixedSchema<T> {
  type: T
  validations: Validation[];
  required(message?: string): MixedSchema<Exclude<T, undefined>>;
  oneOf<U extends T>(arrayOfValues: U[], message?: string): 
    MixedSchema<U | Extract<undefined, T>>
}

น่าเสียดายที่การที่คุณสืบทอดจาก MixedSchema ทำให้เรื่องซับซ้อนขึ้น; คุณต้องการที่จะบอกว่า ตัวอย่างเช่น StringSchema ควรที่จะคงเป็น StringSchema ประเภทใดประเภทหนึ่งหลังจากเรียก required(); มันไม่ควรถูกขยายกลับไปเป็น MixedSchema:

declare class StringSchema<T extends string | undefined> extends MixedSchema<T> {
  email(): this;
  maxWords(maxWords: number, message?: string): this;
}

declare class NumberSchema<T extends number | undefined> extends MixedSchema<T> {
  min(min: number, message?: string): this;
  round(type: 'toUp' | 'toDown' | 'closer'): this;
}

const s = new StringSchema() // StringSchema<string | undefined>
s.email(); // okay
const t = s.required(); // MixedSchema<string>
t.email(); // error! Property 'email' does not exist on type 'MixedSchema<string>';

ดังนั้นเราจะต้องใช้สิ่งที่ซับซ้อนมากขึ้น


คำตอบที่ “ถูกต้อง” ที่นี่คือการใช้สิ่งที่เรียกว่า higher-kinded types ประเภทที่ถูกขอ (แต่ยังไม่ได้ถูกนำไปใช้) ใน microsoft/TypeScript#1213 คุณอาจอยากจะพูดอะไรบางอย่างเช่น: เมธอด required() ของ MixedSchema<T> ควรคืนค่า this<Exclude<T, undefined>> ซึ่งคุณกำลังพยายามใช้ this เหมือนเป็นชนิดที่รับพารามิเตอร์เจนเนอริก ดังนั้นถ้า this เป็น StringSchema<T> มันควรจะเป็น StringSchema<Exclude<T, undefined>> แต่ไม่มีการสนับสนุนตรงๆ สำหรับสิ่งนี้

แทนที่จะเป็นเช่นนั้น เราจำเป็นต้องจำลองมัน และการจำลองทั้งหมดเช่นนี้จะเกี่ยวข้องกับการ "ลงทะเบียน" ประเภทที่เราต้องการที่จะสามารถปฏิบัติเหมือนเป็น generic ของ generic:

type Specify<C extends MixedSchema<any>, T> =
  C extends NumberSchema<any> ? NumberSchema<Extract<T, number | undefined>> :
  C extends StringSchema<any> ? StringSchema<Extract<T, string | undefined>> :
  MixedSchema<T>;

เราได้ระบุรายการย่อยคลาสทั้งหมดของ MixedSchema ที่เราสนใจและอธิบายวิธีการระบุพารามิเตอร์ชนิดของพวกมัน ดังนั้นแม้ว่าเราจะไม่สามารถเขียน this<Exclude<T, undefined>> ได้ แต่เราสามารถเขียน Specify<this, Exclude<T, undefined>> และให้ผลลัพธ์เหมือนกัน

นี่คือตัวอย่างการใช้งานใหม่ของ MixedSchema:

declare class MixedSchema<T> {
  type: T
  validations: Validation[];
  required(message?: string): Specify<this, Exclude<T, undefined>>;
  oneOf<U extends T>(arrayOfValues: U[], message?: string): 
    Specify<this, U | Extract<undefined, T>>
}

และเราสามารถยืนยันได้ว่าตอนนี้มันทำงานได้เหมาะสมในซับคลาสแล้ว:

const s = new StringSchema() // StringSchema<string | undefined>
s.email(); // okay
const t = s.required(); // StringSchema<string>
t.email(); // okay

มาให้แน่ใจว่าประเภทถูกอนุมานตามที่คุณต้องการ:

const form = {
  email: schema.string().required(),
  age: schema.number().min(18),
  gender: schema.string().oneOf(['male', 'female']).required(),
  color: schema.string().oneOf(['red', 'blue', 'green']),
}
/* const form: {
    email: StringSchema<string>;
    age: NumberSchema<number | undefined>;
    gender: StringSchema<"male" | "female">;
    color: StringSchema<"red" | "blue" | "green" | undefined>;
} */

นั่นเป็นสัญญาณที่ดี; พารามิเตอร์ชนิดทั่วไปสำหรับแต่ละฟิลด์ถูกระบุอย่างถูกต้อง และดังนั้น InferType ของคุณควรจะสามารถดึงชนิดของฟิลด์เหล่านั้นได้:

type Form = InferType<typeof form>
/* type Form = {
    email: string;
    age: number | undefined;
    gender: "male" | "female";
    color: "red" | "blue" | "green" | undefined;
} */

ดูดี!

ลิงก์ Playground ไปยังโค้ด