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'
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}`'.
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'
}
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
}
ts
const img = document.querySelector('img')
// ^? const img: HTMLImageElement | null
ts
const img = document.querySelector('img#spectacular-sunset')
// ^? const img: Element | null
img?.src
// ~~~ Property 'src' does not exist on type 'Element'.
ts
interface HTMLElementTagNameMap {
a: HTMLAnchorElement
abbr: HTMLElement
address: HTMLElement
area: HTMLAreaElement
// ... many more ...
video: HTMLVideoElement
wbr: HTMLElement
}
ts
interface ParentNode extends Node {
// ...
querySelector<E extends Element = Element>(selectors: string): E | null
// ...
}
ts
type HTMLTag = keyof HTMLElementTagNameMap
declare global {
interface ParentNode {
querySelector<TagName extends HTMLTag>(
selector: `${TagName}#${string}`
): HTMLElementTagNameMap[TagName] | null
}
}
ts
const img = document.querySelector('img#spectacular-sunset')
// ^? const img: HTMLImageElement | null
img?.src // ok
ts
const img = document.querySelector('div#container img')
// ^? const img: HTMLDivElement | null
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
}
}
ts
const img = document.querySelector('img#spectacular-sunset')
// ^? const img: HTMLImageElement | null
const img2 = document.querySelector('div#container img')
// ^? const img2: Element | null
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
ts
type ToCamelOnce<S extends string> = S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<Tail>}`
: S
type T = ToCamelOnce<'foo_bar'> // type is "fooBar"
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"
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 ...
}
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'?