Skip to content

Item 54: Use Template Literal Types to Model DSLs and Relationships Between Strings

要点

  • Use template literal types to model structured subsets of string types and domain-specific languages (DSLs).
  • Combine template literal types with mapped and conditional types to capture nuanced relationships between types.
  • Take care to avoid crossing the line into inaccurate types. Strive for uses of template literal types that improve developer experience without requiring knowledge of fancy language features.
  • 使用模板字面量类型来建模 string 类型的结构化子集和领域特定语言(DSL)。
  • 将模板字面量类型与映射类型和条件类型结合,以捕获类型之间的细微关系。
  • 小心避免进入不准确类型的范畴。力求使用模板字面量类型来提升开发者体验,而不需要过多依赖复杂的语言特性。

正文

ts
type MedalColor = 'gold' | 'silver' | 'bronze'

💻 playground


ts
type PseudoString = `pseudo${string}`
const science: PseudoString = 'pseudoscience' // ok
const alias: PseudoString = 'pseudonym' // ok
const physics: PseudoString = 'physics'
//    ~~~~~~~ Type '"physics"' is not assignable to type '`pseudo${string}`'.

💻 playground


ts
interface Checkbox {
  id: string
  checked: boolean
  [key: `data-${string}`]: unknown
}

const check1: Checkbox = {
  id: 'subscribe',
  checked: true,
  value: 'yes',
  // ~~~~ Object literal may only specify known properties,
  //        and 'value' does not exist in type 'Checkbox'.
  'data-listIds': 'all-the-lists', // ok
}
const check2: Checkbox = {
  id: 'subscribe',
  checked: true,
  listIds: 'all-the-lists',
  // ~~~~~~ Object literal may only specify known properties,
  //          and 'listIds' does not exist in type 'Checkbox'
}

💻 playground


ts
interface Checkbox {
  id: string
  checked: boolean
  [key: string]: unknown
}

const check1: Checkbox = {
  id: 'subscribe',
  checked: true,
  value: 'yes', // permitted
  'data-listIds': 'all-the-lists',
}
const check2: Checkbox = {
  id: 'subscribe',
  checked: true,
  listIds: 'all-the-lists', // also permitted, matches index type
}

💻 playground


ts
const img = document.querySelector('img')
//    ^? const img: HTMLImageElement | null

💻 playground


ts
const img = document.querySelector('img#spectacular-sunset')
//    ^? const img: Element | null
img?.src
//   ~~~ Property 'src' does not exist on type 'Element'.

💻 playground


ts
interface HTMLElementTagNameMap {
  a: HTMLAnchorElement
  abbr: HTMLElement
  address: HTMLElement
  area: HTMLAreaElement
  // ... many more ...
  video: HTMLVideoElement
  wbr: HTMLElement
}

💻 playground


ts
interface ParentNode extends Node {
  // ...
  querySelector<E extends Element = Element>(selectors: string): E | null
  // ...
}

💻 playground


ts
type HTMLTag = keyof HTMLElementTagNameMap
declare global {
  interface ParentNode {
    querySelector<TagName extends HTMLTag>(
      selector: `${TagName}#${string}`
    ): HTMLElementTagNameMap[TagName] | null
  }
}

💻 playground


ts
const img = document.querySelector('img#spectacular-sunset')
//    ^? const img: HTMLImageElement | null
img?.src // ok

💻 playground


ts
const img = document.querySelector('div#container img')
//    ^? const img: HTMLDivElement | null

💻 playground


ts
type CSSSpecialChars = ' ' | '>' | '+' | '~' | '||' | ','
type HTMLTag = keyof HTMLElementTagNameMap

declare global {
  interface ParentNode {
    // escape hatch
    querySelector(
      selector: `${HTMLTag}#${string}${CSSSpecialChars}${string}`
    ): Element | null

    // same as before
    querySelector<TagName extends HTMLTag>(
      selector: `${TagName}#${string}`
    ): HTMLElementTagNameMap[TagName] | null
  }
}

💻 playground


ts
const img = document.querySelector('img#spectacular-sunset')
//    ^? const img: HTMLImageElement | null
const img2 = document.querySelector('div#container img')
//    ^? const img2: Element | null

💻 playground


ts
// e.g. foo_bar -> fooBar
function camelCase(term: string) {
  return term.replace(/_([a-z])/g, (m) => m[1].toUpperCase())
}

// (return type to be filled in shortly)
function objectToCamel<T extends object>(obj: T) {
  const out: any = {}
  for (const [k, v] of Object.entries(obj)) {
    out[camelCase(k)] = v
  }
  return out
}

const snake = { foo_bar: 12 }
//    ^? const snake: { foo_bar: number; }
const camel = objectToCamel(snake)
// camel's value at runtime is {fooBar: 12};
// we'd like the type to be {fooBar: number}
const val = camel.fooBar // we'd like this to have a number type
const val2 = camel.foo_bar // we'd like this to be an error

💻 playground


ts
type ToCamelOnce<S extends string> = S extends `${infer Head}_${infer Tail}`
  ? `${Head}${Capitalize<Tail>}`
  : S

type T = ToCamelOnce<'foo_bar'> // type is "fooBar"

💻 playground


ts
type ToCamel<S extends string> = S extends `${infer Head}_${infer Tail}`
  ? `${Head}${Capitalize<ToCamel<Tail>>}`
  : S
type T0 = ToCamel<'foo'> // type is "foo"
type T1 = ToCamel<'foo_bar'> // type is "fooBar"
type T2 = ToCamel<'foo_bar_baz'> // type is "fooBarBaz"

💻 playground


ts
type ObjectToCamel<T extends object> = {
  [K in keyof T as ToCamel<K & string>]: T[K]
}

function objectToCamel<T extends object>(obj: T): ObjectToCamel<T> {
  // ... as before ...
}

💻 playground


ts
const snake = { foo_bar: 12 }
//    ^? const snake: { foo_bar: number; }
const camel = objectToCamel(snake)
//    ^? const camel: ObjectToCamel<{ foo_bar: number; }>
//                    (equivalent to { fooBar: number; })
const val = camel.fooBar
//    ^? const val: number
const val2 = camel.foo_bar
//                 ~~~~~~~ Property 'foo_bar' does not exist on type
//                         '{ fooBar: number; }'. Did you mean 'fooBar'?

💻 playground

Released under the MIT License.