Add Custom TypeScript Types to Slate
See original GitHub issueThis post describes a proposal to add custom types to Slate with the following features:
- Extend types in Slate like
Element
andText
with custom properties - Does not require generics at every call site (define custom types once only)
Limitation:
- One custom type definition per project. You can’t have multiple editors in one project with different custom types. You can have multiple instances of editors.
Simple Schema Example
For simplicity, this is a model for a bare bones version of Slate’s types without custom types. I use this to explain the proposal.
export type Element = {
children: Text[]
}
export type Text = {
text: string
}
// gets children text nodes from an element
export function getChildren(element: Element) {
return element.children
}
// gets merged text from children text node
export function getText(element: Element) {
return element.children.map((text) => text.text)
}
The goal:
- We want to be able to customize
Element
andText
. - We need
getChildren
andgetText
to keep working. - We need to use the customized
Element
andText
in our own code without generics at each call site.
How To Use Custom Types
This section explains how to use Custom Types by the end user. I will then explain and show the code for how to add custom types to the schema in the sample above.
// import statement to `./slate` would actually be `slate` but I'm importing
// from a local file.
import { getChildren, Element } from "./slate"
// this is where you customize. If you omit this, it will revert to a default.
declare module "./slate" {
export interface CustomTypes {
Element:
| { type: "heading"; level: number }
| { type: "list-item"; depth: number }
Text: { bold?: boolean; italic?: boolean }
}
}
// This uses the custom element. It supports type discrimination.
// `element.heading` works and `element.depth` fails.
function getHeadingLevel(element: Element) {
if (element.type !== "heading") throw new Error(`Must be a heading`)
// Uncomment `element.depth` and you get a TypeScript error as desired
// element.depth
return element.level
}
// This shows that the regular methods like `getChildren` that are imported
// work as expected.
function getChildrenOfHeading(element: Element) {
if (element.type !== "heading") throw new Error(`Must be a heading`)
return getChildren(element)
}
Here’s a screenshot showing what happens when we uncomment element.depth
and that the proper typescript error shows up:
How It Works
Here is the source code for ./slate.ts
// This would be Element as per Slate's definition
export type BaseElement = {
children: Text[]
}
// This would be Text as per Slate's definition
export type BaseText = {
text: string
}
// This is the interface that end developers will extend
export interface CustomTypes {
[key: string]: unknown
}
// prettier-ignore
export type ExtendedType<K extends string, B> =
unknown extends CustomTypes[K]
? B
: B & CustomTypes[K]
export type Text = ExtendedType<"Text", BaseText>
export type Element = ExtendedType<"Element", BaseElement>
export function getChildren(element: Element) {
return element.children
}
export function getText(element: Element) {
return element.children.map((text) => text.text)
}
This solution combines interface
with type
to get us the best of both.
An interface
supports declaration merging but does not support type discrimination (ie. unions). A type
supports unions and type discrimination but not declaration merging.
The solution uses the declaration merging from interface
and sets its properties to a type
which can be used in unions and declaration merging.
The ExtendedType
takes two generic arguments. The first argument K
is the key within the CustomTypes
interface which the end use can extend. The second argument B
is the base type to use if a custom value is not defined on CustomTypes
and which is being extended.
It works by seeing if CustomTypes[K]
extends unknown
. If it does, it means that the custom property hasn’t been added to CustomTypes
. This is because unknown
only extends unknown
. If this is true (ie. no custom type is provided), we then make the type the Base type B
(ie. Element
or Text
).
If it does not extend unknown
, then a custom type was provided. In that case, we take the custom type at CustomTypes[K]
and add it to the base type B
.
Comments
This solves these typing issues:
- Ability to extend Slate’s types
- Not having to specify types at each call site (ie. reduce type fatigue)
- Support for type unions with type discrimination
Issue Analytics
- State:
- Created 3 years ago
- Reactions:20
- Comments:26 (9 by maintainers)
@thesunny I’m surprised by the way that we are extending types. I haven’t seen any other package require you to do
declare module
to create custom types.Would it make sense to do an API like this:
Small note on this:
I agree, and find that the current declarations approach will run into serious limitations in our project, where we have multiple variations on a Slate editor in the same app with different features enabled for different contexts–which runs quickly into difficulties when required to use a global override (as was already acknowledged in the “limitations” above, I realize).
EDIT: Adding onto this idea, it seems to me that one of the great advantages of the current Slate is the clean way of extending the editor with
withSomeFeature
wrappers. These work well with types when it comes to returning an editor object with new capabilities (eg.function withMyFeature<T extends Editor>(editor: T): T & MyFeatureEditor { ... }
), so it would be ideal for this wrapping to have a way to easily extend the types used by core methods at the same time.