DEVELOPMENT.md
September 27, 2025 ยท View on GitHub
How to add a new parse type
This project works best with a test-driven design. This is a write-up of the neccessary steps.
For example let's say we want to add TypeScript predicates i.e. @returns {x is string}. To do that it makes sense to
follow these steps:
- Add a new result type. In this case it is a
RootResultwhich means that can appear as a root node of an AST. It is important that we use a type that is not used yet and is prefixed withJsdocType. We chooseJsdocTypePredicate. A predicate can have two child elements, we call themleftandright.leftalways has to be a name. We add the following to the filesrc/result/RootResult.ts:
export type RootResult =
// ...
| PredicateResult
/**
* A TypeScript predicate. Is used in return annotations like this: `@return {x is string}`.
*/
export interface PredicateResult {
type: 'JsdocTypePredicate'
left: NameResult
right: RootResult
}
If adding a NonRootResult, you will need to add an exclusion to
assertRootResult in src/assertTypes.ts.
-
Run the tests. With
npm testwe do a typecheck (npm run typecheck), linting (npm run lint) and the unit tests (npm run test:spec). If we runnpm testwe first see that there are multiple type problems in the transforms. -
For the
catharsisTransformand thejtpTransformwe can simply addnotAvailableTransformas these do not support TypeScript predicates (see the respective files for examples). TheidentityTransformsimply needs to return the same type and thestringifytransform should create a valid string output of a given type. These transforms look like this:
// catharsis & jtp
{
// ...
JsdocTypePredicate: notAvailableTransform
}
// identity
{
// ...
JsdocTypePredicate: (result, transform) => ({
type: 'JsdocTypePredicate',
left: transform(result.left) as NameResult,
right: transform(result.right) as RootResult
})
}
// stringify
{
// ...
JsdocTypePredicate: (result, transform) => `${transform(result.left)} is ${transform(result.right)}`
}
-
Specify visitor keys. This is the next type error that will occur in file
src/visitorKeys.ts. These are the properties of our new type which should be visited by tree traversing functions. In our case these are['left', 'right']. -
Add a test. To test we think about an example expression and how we expect it to be parsed. Then we specify these in a fixture test and use
testFixtureto do this. There the typing can guide us to fill all required fields. Check the API docs to find out more about theFixturetype. We create a new test suite attest/fixtures/typescript/predicate.spec.ts:
import { testFixture } from '../Fixture.js'
describe('typescript predicates', () => {
describe('should parse a predicate', () => {
testFixture({
input: 'x is string',
modes: ['typescript'],
expected: {
type: 'JsdocTypePredicate',
left: {
type: 'JsdocTypeName',
value: 'x'
},
right: {
type: 'JsdocTypeName',
value: 'string'
}
}
})
})
})
-
Add new tokens. If we run the test again we will get an error for our unit test and can actually start developing our feature. The message is
Error: The parsing ended early. The next token was: 'Identifier' with value 'is'. It tells us that the lexer was not able to parseisas a token, but treats it as an identifier. To fix this we add'is'to theTokenTypeinsrc/lexer/Token.tsand create a new lexing rule insrc/lexer/Lexer.ts. As this is just a static text token, we can just addmakeKeyWordRule('is')to therulesarray. -
Add a parslet. The next error is
Error: The parsing ended early. The next token was: 'is' with value 'is', which tells us that a parslet is missing. We create a new filesrc/parslets/predicateParslet.tsand usecomposeParsletto create a parslet.
import { composeParslet } from './Parslet.js'
export const predicateParslet = composeParslet({
name: 'predicateParslet'
})
- Decide if it is a prefix or infix parslet (postfix are also infix parslets). The token we recognize is the
is. As this is syntactically an infix operator we can use theparseInfixparameter ofcomposeParslet. Also we need to add theacceptparameter to indicate that we accept tokens of typeis. For infix parslets we also need to specify the precedence which could be explained as the 'binding strength' of the infix operator. For now we will just choosePrecendence.INFIXand see if something else fails.
import { composeParslet } from './Parslet.js'
import { Precedence } from '../Precedence.js'
export const predicateParslet = composeParslet({
name: 'predicateParslet',
precedence: Precedence.INFIX,
accept: type => type === 'is',
parseInfix: (parser, left) => {
}
})
- Implement
parseInfix. Hereparseris the currently used parser andleftis the already parsed part. So we ensure thatleftis indeed a name. If that is the case we can now safelyconsumetheistoken. With this we tell the parser that we can continue parsing the next token and then proceed to assemble the AST and recursively continue parsing therightpart of our expression. To ensure that we indeed get aRootResultfor our right expression we can use the functionassertRoot. This prevents us from getting special results like aKeyValueResultwhich is only valid in certain contexts (for example in object types or function parameter lists). The resulting file looks like this:
import { composeParslet } from './Parslet.js'
import { Precedence } from '../Precedence.js'
import { UnexpectedTypeError } from '../errors.js'
import { assertRootResult } from '../assertTypes.js'
export const predicateParslet = composeParslet({
name: 'predicateParslet',
precedence: Precedence.INFIX,
accept: type => type === 'is',
parseInfix: (parser, left) => {
if (left.type !== 'JsdocTypeName') {
throw new UnexpectedTypeError(left, 'A TypeScript predicate always has to have a name on the left side.')
}
parser.consume('is')
return {
type: 'JsdocTypePredicate',
left,
right: assertRootResult(parser.parseIntermediateType(Precedence.INFIX))
}
}
})
-
Add parslet to grammar. Now we need to tell the parser that we actually want to use this parslet. For this we add the parslet to the
typescriptGrammararray insrc/grammars/typescriptGrammar.ts. -
Run tests and debug until done. In the end we see that all tests pass, and we are done. We can now add some more tests if we like. If you want to run tests on just a particular file, you can temporarily rename the file, e.g., to have the ending
.spec1.tsand then temporarily target "spec1" in.mocharc.json. -
If there are any problems with this guide, feel free to open an issue!